Page 1


А. Л. Марченко

БАРХАТНЫЙ ПУТЬ

Москва Горячая линия - Телеком 2005


УДК 681.3 МЗО ББК 32.97

МЗО

Марченко А. Л. C++. Бархатный путь. - 2-е изд., стереотип. - М.: Горячая линияТелеком, 2005. - 399 с : ил. ISBN 5-93517-056-6. Основная задача книги - обеспечить плавный, «бархатный» переход от интуитивного уровня представления о языке программирования C++ к четким и достоверным знаниям о нем, о его синтаксисе и семантике. Подробно обсуждаются причины и следствия введения различных языковых конструкций, специально подобранные примеры позволяют акцентировать внимание на характерных особенностях языка. Книга может быть использована как в качестве учебного пособия, так и качестве справочника по языку C++. Для программистов, может быть полезна студентам и аспирантам соответствующих специальностей. Адрес издательства в Интернет www.techbook.ru e-mail: radios_hl@mtu-net.ru Справочное издание

Марченко Антон Леонардович

C++. Бархатный путь Редактор И. Н. Карпенко Художник В. Г. Ситников Подготовка оригинал-макета Н. К. Савина Лицензия ЛР № 071825 от 16.03.99 г. Подписано к печати 02.08.2005. Формат 60x90 1/16. Усл. печ. л. 18,2. Изд. № 56. Тираж 1000 экз.

ISBN 5-93517-056-6

© А. Л. Марченко, 1999, 2005 © Оформление издательства «Горячая линия-Телеком», 2005


Предисловие В одном энциклопедическом словаре по поводу того, что такое язык, сказано буквально следующее: "ЯЗЫК, 1) естественный язык, важнейшее средство человеческого общения. Я. неразрывно связан с мышлением; является социальным средством хранения и передачи информации, одним из средств управления человеческим поведением. Я. возник одновременно с возникновением общества в процессе совместной трудовой деятельности первобытных людей. Возникновение членораздельной речи явилось мощным средством дальнейшего развития человека, общества и сознания. Реализуется и существует в речи. Я. мира различаются строением, словарным составом и др., однако всем Я. присущи некоторые общие закономерности, системная организация единиц языка (например, парадигматические и синтагматические отношения между ними) и др. Я. изменяется во времени (см. Диахрония), может перестать использоваться в сфере общения (мёртвые Я.). Разновидности Я. (нац. Я., лит. Я., диалекты, Я. культа и др.) играют различную роль в жизни общества. 2) Любая знаковая система, напр. Я. математики, кино, Я. жестов. См. также Искусственные языки, Язык программирования. 3)..."

C++ также является языком. Его так и называют "язык программирования C++". Это формальный язык. Он служит для описания данных и алгоритмов их обработки на ЭВМ. Несмотря на огромную разницу между естественными и формальными языками, у них есть много общего. Их общие черты неожиданно проявляются в самых разных областях. Например, изучение естественного языка является сложным процессом, включающим как обретение элементарных автоматических навыков, так и восприятие сложных абстрактных понятий. При этом возможность относительно свободного использования языка как средства общения появляется уже на ранних стадиях этого процесса, когда вообще ещё не имеет смысла говорить о знании языка. Так, подавляющее большинство населения любого крупного города общается между собой, используя разговорный язык той страны или той местности, в которой расположен этот город. Практически все, кто проживает в городе, свободно владеет разговорным языком, а вернее, навыками разговорной речи. При этом лишь незначительная часть жителей действительно знает этот язык. Аналогичная ситуация наблюдается и с языками программирования. Первые опыты программирования, подобно использованию навыков разговорной речи, не требуют особых познаний в области формальных языков. Для написания хороших работающих программ не обязательно знать о языке программирования всё. На основе интуитивных представлений об алгоритмах и устройстве компьютера, после анализа нескольких работающих программ или даже фрагментов одной программы, в буквальном смысле по образцам, можно успешно описывать собственные алгоритмы. Однако, грамотная речь всё же невозможна без знания языка, а профессиональное программирование с использованием современных технологий требует глубоких знаний в области языков программирования. Книга "C++. Бархатный путь" адресована, прежде всего, тем, кто уже имеет навыки в области программирования, кто уже знает, что такое компьютер, алгоритм, редактор и транслятор, кто, возможно, уже наблюдал появление на экране дисплея волнующей надписи "Hello, world!" — учащимся старших классов и студентам младших курсов. Однако подобная


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

алфавит и идентификаторы элементарные типы, объявления и определения операции, выражения и операторы функции и их характеристики новые типы и средства их построения конструкторы, деструкторы, функции-члены и операторные функции механизмы наследования, инкапсуляции и полиморфизма шаблоны потоки ввода-вывода средства обработки исключительных ситуаций. В книгу также включено несколько приложений: грамматика языка C++; информация о контроле типов, типизации и прочих важных характеристиках языков программирования; сведения об алгоритмах преобразования, дополнительном коде и о преобразовании дробной части вещественного числа. Эти приложения включены в книгу с единственной целью: чтобы при обсуждении того, ЧТО ДЕЛАЕТСЯ, не возникало вопросой по поводу того, КАК ЭТО ДЕЛАЕТСЯ; элементы теории комплексных чисел. Комплексные числа — наш полигон. Многие конструкции языка C++ мы в дальнейшем будем отрабатывать применительно к множеству комплексных чисел.

"Бархатный путь" не является учебником по программированию и поэтому в этой книге нет полезных советов, которые позволяли бы с помощью обсуждаемых языковых конструкций решать конкретные прикладные задачи. Большинство приводимых здесь примеров, на первый взгляд, кажутся бесполезными и даже бессмысленными. Однако выбор этих примеров далеко не случаен. Дело в том, что C++ является языком "общения" человека с компьютером. Основным "читателем" текстов на языке C++ является транслятор. Это особая программа, в обязанности которой входит проверка правильности текста программы и его последующий перевод на язык процессора — основного устройства ЭВМ, который и обеспечивает выполнение программы. У процессора свой взгляд на программу. Он не имеет никакого представления о содержательной стороне описываемых алгоритмов. Процессору важны адреса, регистры, прерывания. Язык программирования позволяет описывать алгоритмы и данные, но его выразительные возможности не исчерпываются множеством содержательных алгоритмов и связанных с ними структур данных. Даже самые абсурдные с точки зрения программиста предложения языка остаются абсолютно правильными и корректными для транслятора. Примеры, основанные на содержательных алгоритмах, неизбежно оставляют за рамками изложения множества предложений, на которых, порой, и выявляются характерные черты языка.


Несколько слов о названии книги. Понятие бархатного пути связано с железной дорогой. Бархатный путь — это высокое качество железнодорожного полотна и мастерство вождения локомотива. Бархатный путь предполагает мягкое взятие состава с места, его плавный разгон и ведение поезда без толчков, качки, лязга и скрежета. Путешествие по железной дороге — не самый лучший способ знакомства с местностью, по которой проложена эта дорога. Из окна вагона можно не заметить даже очень крутого поворота пути, а узкая полоска защитных насаждений вдоль железнодорожных путей порой кажется густым лесом. Проезжая через населённые пункты, часто удаётся разглядеть лишь заборы и привокзальные постройки. Так и страницы книги о языке программирования — не самое лучшее место для описания алгоритмов. И все же поездка по железной дороге на дрезине, вне расписания, с остановками у переездов и мостов, стрелок, семафоров, поворотных кругов, горок и замздлителей является лучшим способом ознакомиться с устройством дороги. Такое путешествие — прекрасная возможность всё рассмотреть, потрогать и покрутить. При работе над книгой для большинства примеров использовался компилятор, входящий в состав интегрированной среды разработки приложений Borland C++ 4.5. Его следует воспринимать исключительно как простое средство передвижения по "бархатному пути" — своего рода hand car. Выбор транслятора для примеров в этой книги абсолютно не принципиален. Следует лишь иметь в виду, что их выполнение в других инструментальных средах может привести к иным результатам. Это связано с тем, что многие свойства языка C++ (размеры данных основных типов, способы их размещения в различных сегментах памяти и т.д.) зависят от конкретной реализации языка. Впрочем, это нисколько не противоречит тому, о чём говорится в книге. Везде, где это необходимо, в тексте содержатся соответствующие предупреждения. C++ — это сложный, логически стройный и красивый язык. Его хорошее знание приводит к мастерскому владению этим языком. И здесь уже будет по силам решение любой задачи. Выражаю свою благодарность кафедре сейсмометрии и геоакустики Геологического факультета МГУ и Лицею информационных технологий за предоставленную возможность работы над книгой. Замечания и комментарии моих слушателей и студентов были чрезвычайно полезны. Я благодарю преполавателей Высшей компьютерной школы при МГУ, чьи великолепные курсы леЩий оказали большое влияние на мою работу. Особую благодарность выражаю Центру информационных технологий. Я признателен В.А. Гиглавому, Е.А. Зуеву, С.Д. Кузнецову, А.А.Черкасову за поддержку, внимание, советы и внесенные в мою рукопись исправления. Больщое спасибо всем, чья критика позволила мне устранить ошибки и недочеты в ходе работы над книгой.


Введение Язык и грамматика • • • •

Формальный язык является объединением нескольких множеств: множества исходных символов, называемых литерами (алфавит); множества правил, которые позволяют строить из букв алфавита новые слова (правила порождения слов или идентификаторов); множества предопределённых идентификаторов или словаря ключевых слов; множества правил, которые позволяют собирать из слов языка выражения, на основе которых строятся простые и сложные предложения (правила порождения операторов или предложений).

Множество правил порождения слов, выражений и предложений называют грамматикой формального языка или формальной грамматикой. У формального языка много общего с естественным, предложения которого также строятся в соответствии с грамматическими правилами. Однако грамматика естественного языка, подобно наукам о природе, с известной степенью достоверности описывает и обобщает результаты наблюдений за естественным языком как за явлением окружающего мира. Характерные для грамматики естественных языков исключения из правил свидетельствуют о том, что зафиксированная в грамматике языка система правил не может в точности описать все закономерности развития языка. Формальные языки проще естественных языков. Они создаются одновременно с системой правил построения слов и предложений. Исключения из правил в формальном языке могут свидетельствовать лишь о противоречивости и некорректности системы грамматических правил. Однако и здесь не всё так просто. В языке программирования C++ существуют так называемые дополнительные специальные правила соотнесения (соотнесения имени и его области действия - скоро мы встретимся с этими правилами). Так вот эти правила (а, может быть, соглашения?) вполне можно рассматривать как аналоги исключений, поскольку они директивно (по соглашению) отдают предпочтение одной из возможных альтернатив. Грамматические правила можно записывать различными способами. Грамматика естественного языка традиционно описывается в виде грамматических правил на естественном языке. Грамматика формального языка также может быть описана в виде множества правил на естественном языке. Но обычно для этого используют специальные средства записи: формулы и схемы. В качестве примера рассмотрим простой формальный язык. Алфавит этого языка состоит из 17 букв: АБЕЗИЙКНОПРСТУЧШЫ


и одного знака пунктуации — '.' (точки). Рассмотрим систему правил, составляющих грамматику языка. • Правила словообразования (мы не будем вдаваться в их подробное описание) позволяют сформировать из букв языка 5 различных идентификаторов (имён и ключевых слов): КУБ ШАР ПРОЗРАЧНЫЙ СИНИЙ УКРАШАЕТ и ни одним идентификатором больше. • Идентификаторы КУБ и ШАР считаются именами, прочие идентификаторы считаются ключевыми словами. • По весьма отдалённой аналогии с естественным языком, ключевые слова будут играть роли членов предложения и частей речи. • Определение сказуемого (это член предложения): ключевое слово УКРАШАЕТ считается сказуемым. • Определение прилагательного (это часть речи): ключевые слова ПРОЗРАЧНЫЙ и СИНИЙ считаются прилагательными. • Имена играют роль существительных. По аналогии с естественным языком, где предложения строятся из членов предложений, предложения-операторы языка состоят из членов предложений-выражений. Часть выражений считается подлежащими, часть — дополнениями. • Определение подлежащего: выражения-подлежащие состоят из ключевого слова-прилагательного и имени. • Определение дополнения: выражения-дополнения состоят из ключевого слова-прилагательного и имени (одного из двух). • Определение оператора (это последнее правило грамматики): предложение состоит из тройки выражений, самым первым из которых является подлежащее, затем сказуемое и дополнение. Предложение заканчивается точкой. Только что нами была определена грамматика формального языка. Она была описана привычным способом, с помощью нескольких предложений русского языка. Рассмотрим ещё один способ записи этой грамматики — с помощью формул. Запишем сначала в виде формулы определение оператора: о п е р а т о р : : = подлежащее с к а з у е м о е дополнение .

(1)

В этой формуле символ ::= следует читать как "является" или "заменить". Затем определим в виде формул подлежащее и дополнение: подлежащее ::= прилагательное существительное

(2)


дополнение ::= прилагательное существительное

(3)

Следующая формула отражает тот факт, что сказуемым является ключевое слово УКРАШАЕТ. сказуемое

УКРАШАЕТ

(4)

Следующее правило определяет прилагательное: прилагательное

: : = ПРОЗРАЧНЫЙ | СИНИЙ

(5)

Здесь вертикальная черта между двумя ключевыми словами означает, альтернативу (прилагательным в выражении может быть либо ключевое слово ПРОЗРАЧНЫЙ, либо ключевое слово СИНИЙ). Существует еще, по крайней мере, один способ описания альтернативы. Воспользуемся им при определении существительного. Это правило задаёт множество имён: существительное

ШАР

(6)

КУБ

Правила построения предложений в нашем языке оказались записаны с помощью шести коротких формул. Слова, стоящие справа и слева от знака "заменить" принято называть символами формальной грамматики, а сами формулы — грамматическими правилами. Заметим, что символы в формулах грамматики не являются словами в обычном смысле этого слова. Символ в формуле является лишь своеобразным иероглифом, по внешнему виду напоминающим слово. При изменении внешнего вида символов суть формул грамматики нисколько бы не изменилась. Мы всего лишь используем возможность кодирования дополнительной информации с помощью внешнего вида символа. В надежде, что это поможет лучше понять происходящее. Символы, которые встречаются только в левой части правил, называются начальными нетерминальными символами или начальными нетерминалами. Символы, которые встречаются как в левой, так и в правой части грамматических правил называются нетерминальными символами. Символы, которые встречаются только в правой части правил, называются терминальными символами. Воспользуемся этой грамматикой и построим несколько предложений. Алгоритм порождения операторов-предложений и отдельных выражений с помощью правил формальной грамматики очень прост: 1 . Выбрать начальный нетерминал (в нашем случае это оператор) или отдельный нетерминальный символ, найти правило, содержащее этот символ в левой части и заменить его на символ или на последовательность символов из правой части правила. 8


2 . Процесс замены продолжать до тех пор, пока в предложении будут встречаться нетерминальные символы. Выбор нетерминального символа обеспечивает порождение выражения, выбор начального нетерминала обеспечивает вывод оператора: оператор (1) подлежащее сказуемое дополнение . (2) прилагательное существительное сказуемое дополнение . 3) прилагательное существительное сказуемое прилагательное существительное. (4) прилагательное существительное УКРАШАЕТ прилагательное существительное. (5) ПРОЗРАЧНЫЙ существительное УКРАШАЕТ СИНИЙ существительное. (6) ПРОЗРАЧНЫЙ ШАР УКРАШАЕТ СИНИЙ КУБ. Больше терминальных символов нет. По правилам формальной грамматики мы построили первое предложение языка. Ещё одно предложение нашего языка: СИНИЙ КУБ УКРАШАЕТ ПРОЗРАЧНЫЙ КУБ. Формальная грамматика может использоваться не только для порождения предложений, но и для проверки, является ли какая-либо последовательность символов выражением языка. Для этого среди символов исследуемой последовательности надо сначала отыскать терминальные символы и руководствуясь правилами формальной грамматики (справа налево), заменять терминальные символы нетерминальными, а затем "сворачивать" последовательности нетерминальных символов до тех пор, пока не будет получен начальный нетерминал, или единственный нетерминальный символ. Так последовательность символов СИНИЙ КУБ ВЕНЧАЕТ ПРОЗРАЧНЫЙ КУБ. не является оператором языка, поскольку символ ВЕНЧАЕТ не встречается среди нетерминальных символов. В свою очередь, пара терминальных символов СИНИЙ ШАР является выражением нашего языка и может быть как подлежащим, так и дополнением, поскольку может быть преобразована как в нетерминальный символ подлежащее, так и в нетерминальный символ дополнение. Рассмотренный нами способ записи правил грамматики языка называется формами Бэкуса-Наура (сокращенно БНФ). Их не следует бояться. На них надо чаще смотреть. Их надо читать. Символы формальной грамматики складываются в основном из букв родного алфавита. Формы кратки и информативны. Правила, для изложения _


которых обычно требуется несколько фраз естественного языка, часто описываются одной БНФ. После небольшой тренировки чтение форм Бэкуса-Наура становится лёгким и приятным занятием. Впервые БНФ были использованы при описании языка программирования Алгол более 30 лет назад и до сих пор БНФ применяются для описания грамматики при разработке новых языков программирования. Это очень эффективное и мощное средство. Без лишних слов, просто, лаконично, наглядно. Мы часто будем использовать эти формы. При этом нетерминальные символы в БНФ будут выделяться подчёркиванием. Подобно предложениям естественного языка, которые обычно служат основой связного повествования (сказки, романа, научного исследования), предложения формального языка также могут быть использованы для описания всевозможных явлений и процессов. Множества операторов языка программирования служат для создания программ — основного жанра произведений, для которых и используются эти языки. Программы пишут для различных программируемых устройств. К их числу относятся и электронно-вычислительные машины, которые в настоящее время являются наиболее универсальными вычислительными устройствами и основными потребителями программ.

Развитие языков программирования Устройство современных ЭВМ основано на принципах двоичной арифметики, где для представления чисел используются всего две цифры — 0 и 1. В двоичной арифметике любое число кодируется битовыми последовательностями. Вся необходимая для работы ЭВМ информация также хранится в памяти ЭВМ в битовом представлении. Особенности устройства ЭВМ определяют способы её управления. Командами для управления ЭВМ служат всё те же битовые последовательности. Поэтому наиболее естественным способом управления ЭВМ является кодирование информации для ЭВМ в виде всё тех же битовых последовательностей. Для первых ЭВМ альтернативных способов управления просто не существовало. Алфавит языка непосредственного кодирования содержал всего две буквы (а, может быть, цифры?). Можно представить правила словообразования и внешний вид словаря этого языка. Программирование в кодах ЭВМ требует досконального знания системы команд машины и большого внимания. Кроме того, процесс программирования в кодах малоэффективен. Проблема повышения эффективности программирования возникла одновременно с появлением первых вычислительных машин. Первая попытка оптимизации программирования в двоичных кодах заключалась в разработке специальной системы кодирования двоичных машинных команд многобуквенными мнемоническими сокращениями. Программирование в мнемонических командах удобнее для программиста, поскольку мнемонические коды содержат для программиста

То


дополнительную информацию по сравнению с трудно различимыми последовательностями нулей и единиц. Текст подобной программы становится абсолютно непонятным вычислительной машине и требует специального переводчика (или транслятора), который бы заменял мнемонический код исходной двоичной командой. С момента реализации этой идеи кодирование становится программированием. На этом этапе, несмотря на использование мнемонических сокращений, программа жёстко зависит от особенностей работы процессора, поскольку последовательности команд шаг за шагом описывают его работу. Ещё одним знаменательным событием в истории развития языков программирования явилась реализация возможности построения большой программы из отдельных фрагментов программного кода. С этой целью используются подпрограммы — последовательности команд, предназначенные для многократного использования в одной программе. Программирование с использованием подпрограмм требует ещё одной специальной программы, которая обеспечивает сборку единой программы из отдельных фрагментов-подпрограмм и её размещение в памяти ЭВМ. Такие программы называются компоновщиками. Стремление сделать программу независимой от особенностей конкретного процессора, использовать традиционные средства описания алгоритмов и данных, были основой дальнейшего развития языков программирования. Появляются новые языки, позволяющие программисту независимо от устройства конкретной вычислительной машины описывать условия и циклы, структуры данных и массивы, строить сложные выражения с использованием привычных символов математических и логических операций. Подобные языки называются языками высокого уровня. Считается, что эти языки в определённом смысле более близки к естественному языку. С последним утверждением можно не согласится, но одно очевидно: многолетний опыт показал, что использование языков высокого уровня значительно повышает эффективность программирования по сравнению с кодированием и использованием мнемонических сокращений.

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


ходный текст- это то, что видит программист, единица трансляции - это то, что фактически получает на вход транслятор. Так вот эта самая единица трансляции разделяется транслятором на предложения, которые, в свою очередь делятся на элементарные составляющие (лексемы). Каждая лексема распознаётся (имя, ключевое слово, литерал, символ операции или разделитель) и преобразуется в соответствующее двоичное представление. Этот этап работы транслятора называют лексическим анализом. Затем наступает этап синтаксического анализа. На этом этапе из лексем собираются выражения, а из выражений — операторы. В ходе трансляции последовательности терминальных символов преобразуются в нетерминалы. Невозможность достижения очередного нетерминала является признаком синтаксической ошибки в тексте исходной программы. После синтаксического анализа наступает этап генерации кода. При этом предложения языка заменяются инструкциями ассемблера, а затем последовательностями команд конкретной машины. Результат преобразования исходного текста записывается в виде двоичного файла (его называют объектным модулем). Системы программирования, реализующие язык программирования C++, предусматривают стандартные приёмы и средства, которые делают процесс программирования более технологичным, а саму программу более лёгкой для восприятия. К числу таких средств относится система поддержки раздельной компиляции, при которой исходные тексты располагаются в различных файлах, часть из которых может быть независимо от других обработана транслятором. Объектные модули поступают на вход компоновщику, который на основе объектных модулей создаёт загрузочные (или исполняемые) модули, которые выполняются процессором после их размещения в оперативной памяти. Рассмотрим в общих чертах процесс компоновки. Программа строится из инструкций и операторов. В свою очередь, операторы включают выражения, которые состоят из операций и операндов. По крайней мере, части операндов в выражениях должны соответствовать отдельные области оперативной памяти, предназначаемые, например, для сохранения промежуточных результатов вычислений. В ходе трансляции устанавливается соответствие между операндами и адресами областей памяти вычислительной машины. Так вот задача компоновщика состоит в согласовании адресов во всех фрагментах кода, из которых собирается готовая к выполнению программа. Компоновщик отвечает за то, чтобы конкретному операнду выражения соответствовала определённая область памяти. Компоновщик также добавляет к компонуемой программе коды так называемых библиотечных функций (они обеспечивают выполнение конкретных действий — вычисления, вывод информации на экран дисплея и т.д.), а также код, обеспечивающий размещение программы в памяти, её корректное начало и завершение. Процесс разработки программ, состоящих из нескольких файлов эффективнее, особенно если разрабатывается программа большого раз"12


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

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

13


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

цифры от 0 до 9 (назовём их буквами-цифрами),

символ '_' (подчерк — также считается буквой),

набор специальных символов:

" { > . I [ ] + - % /\ I ' : ? < > = ! & # ~ А •* Ш прочие символы. Алфавит C++ служит для построения слов, которые в C++ называются лексемами. Различают шесть типов лексем: идентификаторы, ключевые слова, знаки (символы) операций, символы управления препроцессором, литералы, разделители. Почти все типы лексем (кроме ключевых слов и идентификаторов) имеют собственные правила словообразования, включая собственные подмножества алфавита. Лексемы разделяются разделителями. Этой же цели служит множество пробельных символов, к числу которых относятся пробел, символы горизонтальной и вертикальной табуляции, символ новой строки, перевода формата и комментарии.

Правила образования идентификаторов Рассмотрим правила построения идентификаторов из букв алфавита (в C++ их три): 1 . Первым символом идентификатора C++ может быть только буква. 2 . Следующими символами идентификатора могут быть буквы, буквы-цифры и буквы-подчерки. 3 . Длина идентификатора неограниченна (фактически же длина зависит от реализации системы программирования). 14


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

Ключевые слова и имена Часть идентификаторов C++ входит в фиксированный словарь ключевых слов. Прочие идентификаторы после специального объявления становятся именами. Имена служат для обозначения переменных, типов данных, функций и меток. Обо всём этом позже. Ниже приводится список ключевых слов: asm auto b o o l break case c a t c h char class c o n s t c o n t i n u e default delete d o d o u b l e else e n u m extern float f o r friend g o t o if inline int l o n g new o p e r a t o r private protected public register return s h o r t s i g n e d s i z e o f static s t r u c t s w i t c h template t h i s t h r o w t r y t y p e d e f t y p e i d u n i o n u n s i g n e d virtual v o i d volatile w h i l e

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

S&

• &=

/= >= | |

:: »= #

!= () -= < == ##

I * -> « ?: sizeof

1= *= ->* <= [] new

% + . « = А

delete

%= ++ .* > Л = typeid

& += / » throw

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

15


Литералы В C++ существует пять типов литералов: • целочисленный литерал, • вещественный литерал, • логический, • символьный литерал, • строковый литерал. Это особая категория слов языка. Для каждого подмножества литералов испольльзуются собственные правила словообразования. Мы не будем приводить здесь эти правила. Ограничимся лишь общим описанием структуры и назначения каждого подмножества литералов. После этого правила станут более-менее понятны. Ш Целочисленный литерал служит для записи целочисленных значений и является соответствующей последовательностью цифр (возможно со знаком '-'). Целочисленный литерал, начинающийся с 0, воспринимается как восьмеричное целое. В этом случае цифры 8 и 9 не должны встречаться среди составляющих литерал символов. Целочисленный литерал, начинающийся с Ох или ОХ, воспринимается как шестнадцатеричное целое. В этом случае целочисленный литерал может включать символы от А или а, до F или f, которые в шестнадцатеричной системе эквивалентны десятичным значениям от 10 до 15. Непосредственно за литералом может располагаться в произвольном сочетании один или два специальных суффикса: U (или и) и L (или I). • Вещественный литерал служит для отображения вещественных значений. Он фиксирует запись соответствующего значения в стандартной или научной нотациях. В научной нотации мантисса отделяется от порядка литерой Е или е). Непосредственно за литералом могут располагаться один из двух специальных суффиксов: F (или f) и L (или I). И Логические литералы служат для записи логических значений, которых в языке C++ всего два: истина (true) и ложь (false). • Значением символьного литерала является соответствующее значения ASCII кода (это, разумеется, не только буквы, буквыцифры или специальные символы алфавита C++). Символьный литерал представляет собой последовательность из одной или нескольких литер, заключённых в одинарные кавычки. Символьный литерал имеет несколько форматов представления: обычный, восьмеричный и шестнадцатеричный. Например, литера Z может быть представлена литералом 'Z', а также литералами '\132' и '\х5А'. Допустимый диапазон для обозначения символьных литералов в восьмеричном представлении ограничен восьмеричными числами от 0 до 377. Допустимый диапазон для обозначения символьных литералов в шестнадцатеричном представлении ограничен шестнадцатеричными числами от 0x0 до OxFF. Литеры, которые используются в качест-


ве служебных символов при организации формата представления или не имеют графического представления, могут быть представлены с помощью ещё одного специального формата. Ниже приводится список литер, которые представляются в этом формате. К их числу относятся литеры, не имеющие графического представления, а также литеры, которые используются при организации структуры форматов. Список литер организован по следующему принципу: сначала приводится представление литеры в специальном формате, затем — эквивалентное представление в шестнадцатеричном формате, далее — обозначение или название литеры, за которым приводится краткое описание реакции на литеру (смысл литеры). \0 \а \Ь \f \п \r \t \v \\ V \" \?

\х00 \хО7 \хО8 \xOC \хОА \xOD \xO9 \xOB \х5С \х27 \х22 \x3F

null Ье1 bs ff If cr ht vt \

пустая литера сигнал возврат на шаг перевод страницы перевод строки возврат каретки горизонтальная табуляция вертикальная табуляция обратная косая черта

?

• Строковые литералы являются последовательностью (возможно, пустой) литер в одном из возможных форматов представления, заключённых в двойные кавычки. Строковые литералы, расположенные последовательно, соединяются в один литерал, причём литеры соединённых строк сохраняют свою "самостоятельность". Так, например, последовательность строковых литералов "\xF" "F" после объединения будет содержать две литеры, первая из которых является символьным литералом в шестнадцатеричном формате '\F, второй — символьным литералом 'F'. Строковый литерал и объединённая последовательность строковых литералов заканчиваются специальной пустой литерой. Она не имеет графического представления. При отсутствии информации о длине строкового литерала пустая литера оказывается единственным индикатором конца строкового литерала. Пустая литера очень часто используется в программах, связанных с символьными и строковыми преобразованиями, обработкой текстовой информации.

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


C++ состоит из выражений и может содержать вложенные операторы. Выражение является частью оператора и строится на основе множества символов операций, ключевых слов и операндов. Операндами могут быть всё те же выражения, включая литералы и имена. Одной из характеристик выражения является его значение, которое вычисляется на основе значений операндов по правилам, задаваемым операндами. Следует иметь в виду, что в ряде книг по языку программирования C++ (в частности, в книге Б. Строуструпа "Язык программирования C++") используется иная терминология. При переводе книги операции были названы операторами, а операторы (то есть предложения) - инструкциями. Последнее обстоятельство не столь важно. Главное - это понимать, о чём идёт речь и не путать понятия.

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

Объекты и функции Объектом называют область памяти, выделяемую для сохранения какой-либо информации. Эта информация в данной области памяти кодируется двоичной последовательностью. Такие последовательности составляют множество значений объекта. Резервирование области памяти предполагает обязательную возможность доступа к ней. Обращение к объекту обеспечивается выражениями. Выражение в языке программирования является единственным средством взаимодействия с объектами. Частным случаем выражения является имя объекта. Объекты, которые используются исключительно для сохранения информации, называются константами. Обычно константе присваивается значение в момент создания объекта. Дальнейшие изменения значения константы не допускаются. Объекты, которые допускают изменение зафиксированных в них значений, называются переменными. Определение может сопровождаться инициализацией, однако присвоение значения переменной может быть _


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

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

Типы Тип является основной характеристикой объекта и функции. Тип определяет, что и как следует делать со значениями объектов и функций. Тип определяет структуру и размеры объекта, диапазон и способы интерпретации его значения, множество допустимых операций. В широком смысле тип определяет поведение объекта. Поскольку конкретное значение может быть зафиксировано в области памяти, которая соответствует объекту определённого типа, можно также говорить о типе значения. Значение выражения вычисляется. При этом значение константы только читается, а значение переменной может быть изменено. Таким образом, имеет смысл также говорить о типе выражения. При этом тип оказывается важнейшей характеристикой языка. _


Можно предположить существование языка с единственным типом. Такой язык можно считать нетипизированным языком. Для нетипизированного языка характерен фиксированный размер объектов, единый формат представления информации, унифицированные способы интерпретации значений. Как ни странно, нетипизированный язык одинаково неудобен для решения задач в любой конкретной предметной области. К обработке символьной информации или решению сложных вычислительных задач транслятор нетипизированного языка относится одинаково. Для него все объекты одинаковые. Так что реализация алгоритмов сравнения символьных строк, вычисление значений тригонометрических функций, корректное прочтение и запись значений переменных и констант, способы интерпретации информации, применение разнообразных операций к данным (при анализе символьной информации бессмысленны операции умножения и деления) и многие другие проблемы оказываются исключительно проблемами программиста.«Больше проблем — больше ошибок. Здесь имеет смысл обратиться к приложениям, связанным с типизацией и контролем типов. В следующих разделах мы будем говорить о типах объектов. Типы функций будут рассмотрены позже.

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

20

char — символьный тип. Объект типа char занимает в памяти один байт, модификации типа unsigned и signed (signed char, unsigned char) определяют диапазон значений типа: unsigned (беззнаковый) от 0 до 255, signed (знаковый) от -128 до 127. short int, int, long int, signed short, unsigned short, signed int, unsigned int, signed long, unsigned long - короткий целый, длинный целый и их знаковые и беззнаковые модификации. Представляют целые типы. Объекты этих типов занимают в памяти участки различной длины. Это определяет диапазон значений этих типов. Конкретные размеры и предельные значения определяются реализацией. По крайней мере, для Borland C++ 4.5 (16-разрядная платформа), основные характеристики целочисленных типов выглядят следующим образом:


Тип данных signed char unsigned char signed short enum unsigned short signed int unsigned int signed long unsigned long

Байты 1 1 2 2 2 2 2 4 4

Биты 8 8 16 16 16 16 16 32 32

Min - 128 0 -32768 -32768 0 -32768 0 -2147483648

0

Max 127 255 32767 32767 65535 32767 65535 2147483647 4294967295

float, double, long double - типы с плавающей точкой (или вещественные типы) одинарной, двойной и расширенной точности. Представляют вещественные числа. Объекты этих типов занимают в памяти участки различной длины. Конкретные размеры и диапазон значений определяются реализацией. Модификатор типа входит в число имён основных типов. Плавающие типы используются для работы с вещественными числами, которые представляются в форме записи с десятичной точкой, так и в "научной нотации". Разница между нотациями становится очевидной из простого примера, который демонстрирует запись одного и того же вещественного числа в различных нотациях. 297.7 2.977*102 2.977Е2

И ещё один пример: 0.002355 2.355*10"3 2.355Е-3

В научной нотации слева от символа Е записывается мантисса, справа — значение экспоненты, которая всегда равняется показателю степени 10. Для хранения значений плавающих типов в памяти используется специальный формат представления вещественных чисел. Этот формат называется IEEE форматом. Ниже представлены основные характеристики типов данных с плавающей точкой (опять же, для платформы Win16, версии языка Borland C++ 4.5): Тип данных float double long double

Байты 4 8 10

Биты 32 64 80

Min 3 .4Е-38 1 7Е-308 3. 4Е-4932

Max 3 .4E+38 1 7E+308 3. 4E+4 932

bool — логический тип. Множество его значений включает всего два элемента - истина (true) и ложь (false). _


Логические, символьные и целые типы в C++ называются интегральными типами. Вместе с вещественными, интегральные типы составляют множество арифметических типов. Диапазоны значений арифметических типов (в стандарте языка оговариваются лишь минимально допустимые значения) определяются конкретной реализацией. Обычно выбор этих характеристик диктуется эффективностью использования вычислительных возможностей компьютера. Зависимость языка от реализации создаёт определённые проблемы переносимости. C++ остаётся машиннозависимым языком. Объединять логические, интегральные и вещественные типы в одно подмножество типов позволяет принятое в C++ соглашение о соотношении значений, согласно которому ненулевое значение ассоциируется с логическим значением true, а нулевое со значением false. На основе элементарных (или арифметических) типов в C++ можно определить новые типы, к числу которых относятся массивы, указатели и ссылки, а также перечисления, структуры и классы. Массивы - это последовательности объектов одного и того же типа. Упорядоченное множество объектов также можно рассматривать как объект. Множества типов, представляющих массивы, определяются на основе встроенных или ранее объявленных типов. Свойства массивов как типов целиком зависят от свойств исходных типов, на основе которых определяются эти массивы. Указатели - множества типов, представляющих объекты, способные сохранять в качестве значений адреса других объектов, их составных частей, а также размещаемых в оперативной памяти методов или функций. Указатели имеют фиксированный размер, для них определено специфическое множество операций. Несмотря на унифицированную структуру и общие принципы интерпретации значений указателей, типов указателей на объекты столько же, сколько и самих типов. Тип указатель на объект соответствует типу объекта (или функции), адрес которого присваивается указателю. Ссылки — множества типов, представляющих модифицированные варианты указателей. Являясь указателем, ссылка обеспечивает иллюзию непосредственной работы с объектом, а не с адресом этого объекта. Кроме того, в C++ существует специальный тип void, указывающий на отсутствие какого-либо значения. В C++ для обозначения массивов, указателей и ссылок используются особые синтаксические конструкции. О перечислениях, структурах и классах речь впереди.

Объявление и определение В справочном руководстве по языку программирования C++ Б.Строуструпа на первых страницах вводится понятие объявления. Для C++ это одно из основных понятий. Мы воспроизводим его практически дословно, поскольку трудно предложить более чёткую и лаконичную фор_


мулировку (в справочном руководстве вместо термина "определение" используется термин "описание"). Объявление вводит одно или более имён в программу. Объявление является определением, если только оно НЕ объявляет функцию без спецификации тела, НЕ содержит спецификатора extern при отсутствии инициализатора и тела функции, НЕ является объявлением статического члена в объявлении класса, НЕ является объявлением имени класса, НЕ является объявлением имени типа. Новые неизвестные (пока!) понятия не должны служить поводом для недоумения. На этом этапе важно осознать тот факт, что ОПРЕДЕЛЕНИЕ ЯВЛЯЕТСЯ ЧАСТНЫМ СЛУЧАЕМ ОБЪЯВЛЕНИЯ. И пока этого вполне достаточно.

Объявление переменных В языке определены несколько типов операторов. Каждый из них играет в программе строго определённые роль. Мы приступаем к изучению синтаксиса операторов объявления. С помощью объявлений в программу вводятся имена для обозначения используемых в программе сущностей, представляемых различными языковыми конструкциями. Помимо ввода новых имён, объявление заключает в себе дополнительную информацию о характеристиках той сущности, с которой связывается объявляемое имя. Например, переменная — специально выделяемая в программе область памяти для временного сохранения информации характеризуется типом, классом памяти, временем жизни, и множеством других свойств, представляемых различными модификаторами. Прежде чем приступить к описанию грамматики объявления переменных, введём для употребления в БНФ пару новых символов: [ и ]. Эти символы мы будем называть синтаксическими скобками. Заключение какого либо символа в синтаксические скобки означает, что этот символ в данной БНФ, а значит и в описываемой конструкции языка является необязательным элементом. Договоримся также об использовании в БНФ ещё одного символа. Этот символ будет иметь вид последовательности из пяти звёздочек, стоящих непосредственно за символом ::= в левой части формулы. Таким образом, содержащая этот символ БНФ будет выглядеть так: Описатель : : = * * * * * или даже так: : : =

*****

Этот символ мы будем называть прерывателем БНФ. Он будет означать, что определение нетерминального символа прерывается и будет продолжено позже.


Оператор ::= ОператорОбъявления ;;ш ***** ОператорОбъявления : := Объявление Объявление ::= ОбъявлениеПеременной :;= ***** ОбъявлениеПеременной ::= ОбъявлениеПеременнойОсновногоТипа •

=

*

*

*

*

*

ОбъявлениеПеременнойОсновногоТипа ::= [СписокСпвцификаторовОбгьяв ления ] [СписокОписателей]; СписокСпецификаторовОб'ъявления : : = [СписокСпецификаторовОбгьявления] СпецификаторОбъявления СпецификаторОбъявления ::= СпецификаторКлассаПамяти СпецификаторТипа суСпецифи к атор fctCпeцификaтop СпецификаторКлассаПамяти : := := := := СпецификаторТипа : := := := :=

auto register static extern

ИмяПростогоТипа СпецификаторПеречисления СпецификаторКласса УточнённыйСпецификаторТипа

УточнённыйСпецификаторТипа ::= КлючевоеСловоКласса Идентификатор ::= КлючевоеСловоКласса ИмяКласса ::= enum ИмяПеречисления ИмяПростогоТипа

24

= = = = = = = = = = —

char short int long signed unsigned float double void bool ******


суСпецификатор

::= const ::= volatile

СписокОписателей

::= ОписательИнициализатор ::= СписокОписателей , ОписательИнициализатор

ОписательИнициализатор

::= Описатель

[Инициализатор]

Описатель ::= сМмя ::= (Описатель) •

:

=

Инициализатор

*

*

*

*

*

: := = Выражение ::= (СписокВыражений) :;= *****

Выражение ::= Литерал ::= Имя ::= ***** СписокВыражений

::= ВыражениеПриевайвания ::= СписокВыражений , ВыражениеПрисваивания

сЗИмя : : = Имя ::= ИмяКласса ::= ~ ИмяКласса ::= ОписанноеИмяТипа ::= Квалифициров анноеИмяТипа ВыражениеПрисваивания - этот нетерминальный символ используется в Справочном руководстве по C++ для обозначения элементов списка выражений. Это всего лишь частный случай выражения. с!Имя - это имя того, что описывается описателем в данном объявлении. В "Справочном руководстве по языку программирования C++" английский эквивалент понятия описатель — declarator. Обилие нетерминальных символов, производных от символа Имя не должно вызывать никаких затруднений. В конечном счёте, нетерминальные символы ИмяКласса , ОписанноеИмяТипа , ИмяПеречисления (об этом позже) - являются обыкновенными идентификаторами. Всё зависит от контекста объявления. Что объявляется, так и называется. Именующее класс ОписанноеИмяТипа одновременно является и ИменемКласса . ИмяКласса : : = И д е н т и ф и к а т о р ОписанноеИмяТипа : : = И д е н т и ф и к а т о р ИмяПеречисления::= Идентификатор

25


Мы располагаем достаточно большим (хотя пока и неполным) множеством БНФ, которые задают правила построения синтаксически безупречных операторов объявления переменных в C++. Согласно приведённым правилам, оператором объявления переменных будет считаться пустой оператор ; . Он состоит из точки с запятой. Между прочим, точкой с запятой заканчиваются все простые операторы C++. Операторами объявления будут также считаться и такие последовательности спецификаторов объявления: auto r e g i s t e r s t a t i c extern char short i n t const; int ; С точки зрения синтаксиса это правильные предложения. C++ позволяет описывать данные и алгоритмы их обработки. Вместе с тем, правильно построенная цепочка слов языка может быть семантически неверной, а также абсолютно бессмысленной, то есть не нести никакой информации ни о данных, ни о шагах конкретного алгоритма. Большая часть порождаемых с помощью грамматических правил предложений оказывается семантически некорректными и лишёнными всякого смысла. Грамматика не отвечает за семантику и тем более за смысл предложений. Она всего лишь описывает правила построения операторов. Тем не менее, транслятор обеспечивает частичный семантический контроль предложений. Поэтому ранее рассмотренные объявления и воспринимаются как ошибочные. Можно было бы усовершенствовать систему обозначений, которая применяется в БНФ, а заодно и сделать более строгими правила синтаксиса. Например, можно было бы добиться того, чтобы пустые операторы воспринимались как синтаксически некорректные предложения. Однако это не может привести к кардинальному решению проблемы семантического контроля и тем более контроля за смыслом.

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


результате которых в ходе реализации вычислительного процесса объект может размещаться не в оперативной памяти, а в регистрах процессора. ^Спецификатор const является уведомлением о том, что объект, который, возможно, будет создан на основе объявления, предназначается только для хранения информации, и любые изменения сохраняемой информации недопустимы. Существует единственная возможность присвоения такому объекту требуемого значения. Оно должно присваиваться в момент объявления. В операторе объявления можно также присваивать значения переменным. Для присвоения начальных значений используются инициализаторы (Инициализатор). В языке используется несколько вариантов инициализаторов. В частности, инициализатор может состоять из операции, обозначаемой символом '=' (это не операция присвоения!) и специальной конструкции, обозначаемой нетерминальным символом Выражение, которое в этом случае располагается непосредственно за операцией присвоения. Альтернативной формой инициализатора является заключённый в круглые скобки список выражений. Существует ещё один вариант инициализатора, который используется для присвоения значений массивам. Он представляет собой конструкцию, состоящую из операции присвоения и заключённого в фигурные скобки списка инициализаторов. Но об этом позже. Инициализатор превращает объявление в определение. Выражение представляет значение, которое присваивается создаваемому объекту. Приведём несколько примеров операторов объявления. int

intVal;

Объявлена (и определена) переменная типа int с именем intVal. Её значение зависит от контекста, в котором используется это объявление. long double dbllnitVal = -2.51D5; long double dbllnitVal (-2.51D5); Объявлена (и определена) переменная типа long double, в момент объявления (определения) ей присваивается значение -251000. Второй оператор представляет альтернативную форму объявления с инициализацией - заключённый в круглые скобочки и состоящий из одного элемента список выражений. const long double lngdblFirstVal = dbllnitVal; Оператор объявления состоит из списка спецификаторов, содержащего а/Спецификатор const и спецификаторы основного типа long и double, что является указанием для создания в памяти объекта-константы с именем lngdblFirstVal в момент объявления с помощью инициализатора 27


константе присваивается значение, которое ранее было присвоено переменной dbllnitVal. const float fltVal_l = 3.62, fltVal_2 = 5.12; Оператор объявления состоит из списка спецификаторов, содержащего ^Спецификатор const и СпецификаторОсновногоТипа float, что является указанием для создания в памяти объектов-констант с именами fltVaM и fltVal_2 в момент объявления с помощью инициализаторов константам присваиваются значения 3.62 и 5.12. И ещё одно важное замечание, связанное с определением и инициализацией. В C++ различается определение и определение с предварительной инициализацией. Объявить имя переменной и определить эту самую переменную С ЕЁ ОДНОВРЕМЕННОЙ ИНИЦИАЛИЗАЦИЕЙ можно далеко не везде, где разрешено определение переменной БЕЗ ИНИЦИАЛИЗАЦИИ. Инициализация воспринимается как отдельное самостоятельное действие, выполнение которого требует дополнительных условий. В заключение этого раздела следует обратить особое внимание на ранее приведённое подмножество форм Бэкуса-Наура, связанное с синтаксисом описателя: Описатель : := сШмя ::= (Описатель) Как следует из этих БНФ, заключённый в круглые скобки описатель остаётся описателем. Из этого следует, что входящее в качестве описателя в объявление имя переменной может быть заключено в круглые скобки. Таким образом, следующие объявления оказываются полностью эквивалентными: int int int int

MyVal; (MyVal); ((MyVal)); (((MyVal))); Эквивалентными также оказываются следующие определения:

float float float float

fVal = 3.14; ((fVal)) = 3.14; fVal (3.14); (((fVal)>)(((((3.14) ))));

Так что количество парных скобок в описателе и инициализаторе ничем не ограничено.

28


Рекомендации по наименованию объектов Имена — это идентификаторы. Любая начинающаяся с буквы случайным образом составленная последовательность букв, цифр и знаков подчёркивания с точки зрения грамматики языка идеально подходит на роль имени любого объекта, если только она начинающаяся с буквы. Фрагмент программы, содержащий подобную переменную, будет синтаксически безупречен. И всё же имеет смысл воспользоваться дополнительной возможностью облегчить восприятие и понимание последовательностей операторов. Для этого достаточно закодировать с помощью имён содержательную информацию. Желательно создавать составные осмысленные имена. При создании подобных имён в одно слово можно "уместить" предложение, которое в доступной форме представит информацию о типе объекта, его назначении и особенностях использования.

Комментарии: возможность выразиться неформально C++, как и любой формальный язык непривычен для восприятия и в силу этого в ряде случаев может быть тяжёл для понимания. В C++ предусмотрены дополнительные возможности для облегчения восприятия текстов программ. Для этого используются комментарии. Комментарии — это любые последовательности символов алфавита C++, заключённые в специальные символы. Эти символы называются символами — комментариями. Существуют две группы символов — комментариев. К первой группе относятся парные двухбуквенные символы /* и */. Вложенные комментарии не допускаются. Ближайший к открывающему символу /* символ */ в тексте программы воспринимается как конец закомментированной области. Ко второй группе символов — комментариев относится пара, состоящая из двухбуквенного символа // и не имеющего графического представления пробельного символа новой строки. Последовательность символов, ограниченная символами комментариев, исчезает из поля зрения транслятора. В этой "мёртвой зоне" программист может подробно описывать особенности создаваемого алгоритма, а может просто "спрятать" от транслятора множество предложений на C++. Фрагменты кода, уже содержащие комментарии, могут быть скрыты с использованием альтернативного набора символов-комментариев. Это позволяет избежать ситуации вложенных комментариев.

29


Структура исходного файла Мы переходим к описанию синтаксиса элементов исходного файла, но, прежде всего, определим ещё одну категорию спецификаторов объявления. СпецификаторОбъявления ::= f^Спецификатор ;= ***** : f ^Спецификатор ::= inline ::= virtual ^Спецификатор используется при объявлении и определении функций. Их назначение будет обсуждаться в дальнейшем. ЭлементИсходногоФайла

: : = СписокИнструкцийПрепроцессора : : = СписокОператоров

СписокОператоров Оператор

: : = [СписокОператоров] Оператор

: : = ОператорОбгьявления ;;= *****

ОператорОбгьявления : := Объявление Объявление : := ОбгьявлениеФункции ::= ОпределениеФункции ***** : : = ОбгьявлениеФункции : : = [ СписокСпецификаторовОбгьявления ] Описатель fСпецификацияИсключения]; ОпределениеФункции ::= [ СписокСпецификаторовОбгьявления ] Описатель [СпецификацияИсключения] ТелоФункции Описатель := := := :=

ИмяОписатель рЪгОперация Описатель Описатель (СхемаОбгьявленияПараметров) Описатель [ [КонстантноеВыражение./] (Описатель)

ИмяОписатель ::= Имя

"зо"~


;;= ptr-Операция

*****

: : = * [СписокСУОписателей] : : = & [СписокСУОписателейУ

СписокСУОписателей СУОписатель

: : = СУОписатель

: : = const

с^гИнициализатор

|

fСписокСУОписателейУ

volatile

: : = *****

СпецификацияИсключения

: : = *****

О последних двух нетерминалах позже. КонстантноеВыражение

: : = УсловноеВыражение

Свойства константного выражения мы также обсудим позже. УсловноеВыражение

: : = *****

СхемаОбгьявленияПараметров

: := /'СписокОб'ьявленийПараметров ] [. . . ] : : = СписокОб'ьявленийПараметров, . . .

СписокОб'ьявленийПараметров

ОбтьявлениеПараметра

: : = Обт.явлениеПараметра : : = ^СписокОбт.явленийПараметров, ] ОбъявлениеПараметра

: := СписокСпецификаторовОб-ъявления Описатель : : = СписокСпецификаторовОб'ьявления Описатель Инициализ а т о р СписокСпецификаторовОб г ьявления [АбстрактныйОписатель] /"Инициализ а т о р У

АбстрактныйОписатель

: : = ptrOnepatpiH /АбстрактныйОписатель/ : : = /"АбстрактныйОписатель ] (СхемаОбъявленияПараметров) [СписокСУОписателей] ::= [АбстрактныйОписатель] [ [КонстантноеВыражение./ ] : : = (АбстрактныйОписатель)

31


БНФ, раскрывающая смысл нетерминала АбстрактныйОписатель, также проста, как и все прочие БНФ. Достаточно беглого взгляда, чтобы понять, что в роли этого самого абстрактного описателя могут выступать операции *, &, даже пара символов Ц, между которыми может располагаться константное выражение. Абстрактный описатель можно также поместить в круглые скобки. Если обычный описатель предполагает какое-либо имя, то абстрактный описатель предназначается для обозначения неименованных (безымянных) сущностей. ТелоФункции ::= СоставнойОператор СоставнойОператор ::= {[СписокОператоров]} Фигурные скобки — характерный признак составного оператора. СписокОператоров ::= Оператор ::= СписокОператоров Оператор Оператор : := ОператорОбъявления СписокИнструкцийПрепроцессора ::= [СписокИнструкцийПрепроцессора] ИнструкцияПрепроцессора ИнструкцияПрепроцессора ::= # ::= Макроопределение ::= ФункциональноеМакроопределение Макроопределение ::= tdefine Идентификатор СтрокаЛексем ФункциональноеМакроопределение ::= #define Идентификатор (СписокИдентификаторов) СтрокаЛексем СписокИдентификаторов ::= Идентификатор ::= СписокИдентификаторов, Идентификатор СтрокаЛексем ::= Лексема ::= СтрокаЛексем Лексема Составной оператор также называют блоком операторов (или просто блоком). Несмотря на значительное количество пропусков в приведённых выше БНФ, содержащейся в них информации о синтаксисе исходного файла вполне достаточно для реконструкции его общей структуры.

32


Сейчас мы восстановим структуру программы. На содержание этой "программы" можно не обращать никакого внимания. В данный момент важен лишь синтаксис. СписокИнструкцийПрепроцессора СписокОператоров Макроопределение Оператор Оператор Оператор Оператор #define Идентификатор СтрокаЛексем ОбъявлениеПеременной ОбъявлениеФункции ОпределениеФункции ОпределениеФункции #define IdHello "Hello..." int *pIntVal[5]; /* Объявлена переменная типа массив указателей размерности 5 на объекты типа int с именем plntVal. */ СпецификаторОбъявления Описатель; СпецификаторОб-ьявления Описатель ТелоФункции СпецификаторОбъявления Описатель ТелоФункции #define IdHello "Hello..." int *pIntVal[5]; int Описатель (СхемаОбъявленияПараметров); float Описатель (СпецификаторОбъявления Имя ) ТелоФункции unsigned int MyFun2 (int Paraml, ...) СоставнойОператор #define IdHello "Hello..." int *pIntVal[5]; int MyFunl ( СпецификаторОб'ъявления , СпецификаторОб'ъявления ДбстрактныйОписатель ИнициалиЗатор, ); float MyFun2 (СпецификаторОбъявления ИмяОписатель) ТелоФункции unsigned int MyFun3 (int Paraml, ...) {СписокОператоров} #define IdHello "Hello..." int *pIntVal[5]; int MyFunl (float, int *[5] = plntVal); /*

33


Объявление функции. В объявлении второго параметра используется абстрактный описатель — он описывает нечто абстрактное, а, главное, безымянное, вида *[5]. Судя по спецификатору объявления int, расположенному перед описателем, "нечто" подобно массиву указателей на объекты типа int из пяти элементов (подробнее о массивах после). И эта безымянная сущность инициализируется с помощью инициализатора. Сейчас нам важно проследить формальные принципы построения исходного файла. Прочие детали будут обсуждены ниже. */ float MyFun2 (char chParaml) { СписокОператоров } unsigned int MyFun3 (int Paraml, ...) {СписокОператоров) #define IdHello "Hello..." int *pIntVal[5]; int MyFunl (float, int *[5] = plntVal); // Объявление функции. // Определены две функции... float MyFun2 (char chParaml) { extern int ExtlntVal; char *charVal; } unsigned int MyFun3 (int Paraml, ...) ( const float МММ = 233.25; int MyLocalVal; ) Только что на основе БНФ было построено множество предложений, образующих единиицу трансляции. Наша первая программа ничего не делает это всего лишь несколько объявлений. Тем не менее, этот пример показывает, что в программе нет случайных элементов. Каждый символ, каждый идентификатор программы играет строго определённую роль, имеет собственное название и место в программе. Наша программа - это множество инструкций для препроцессора и операторов, часть из которых играет роль объявлений. С их помощью кодируется необходимая для транслятора информация о свойствах объектов. Другая часть операторов является определениями и предполагает в ходе выполнения программы совершение определённых действий по расположению объектов в одном их сегментов памяти. После трансляции текста предложения преобразуются во множество команд процессора. При всём различии операторов языка и команд процессора, трансляция правильно написанной программы обеспечивает точную передачу заложенного в исходный текст смысла (или семантики __


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

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


мандой (или серией команд). Выборка очередной команды должна производиться с максимальной эффективностью. При этом команды не должна перемешиваться с данными, которые процессор также выбирает из памяти и записывает в память в ходе выполнения программы. Поэтому совершенно естественным является сохранение кода программы и используемых в программе данных в различных сегментах. Среди сегментов различают: • сегмент кода (адресуется регистром программного сегмента), где располагается код программы, Ш сегмент стека (или локальная память, адресуется регистром сегмента стека), где располагается вся необходимая для выполнения программы оперативная информация, • сегмент данных (или глобальная память, адресуемая регистром сегмента данных), где располагаются глобальные объекты, которые создаются на основе информации, заключённой в операторах объявления. Сегмент данных является областью памяти с уникальными свойствами. Эта область памяти в течение всего жизненного цикла программы сохраняет глобальные объекты. Эти объекты доступны из всех модулей и могут использоваться для организации взаимодействия различных вычислительных процессов, возможно, описываемых в различных файлах программы. Благодаря своей доступности глобальные объекты требуют внимательного обращения. Случайное изменение значения переменной в результате ошибки программирования в каком либо файле может привести к ошибкам в работе вычислительных процессов, которые были описаны совсем в других файлах и используют глобальные переменные для своих собственных целей. С учётом архитектуры современных процессоров программа может использовать дополнительно множество сегментов различного назначения и длины.

Класс памяти Класс памяти определяет порядок размещения объекта в памяти. Различают автоматический и статический классы памяти. C++ располагает четырьмя спецификаторами класса памяти: auto register static extern по два для обозначения принадлежности к автоматическому и статическому классам памяти. В свою очередь, статический класс памяти может быть локальным (внутренним) или глобальным (внешним). _


Следующая таблица иллюстрирует иерархию классов памяти. Динамический класс памяти Автоматический Регистровый auto register

Статический класс памяти Локальный Глобальный static Extern

Спецификаторы позволяют определить класс памяти определяемого объекта: • auto. Этот спецификатор автоматического класса памяти указывает на то, что объект располагается в локальной (или автоматически распределяемой) памяти. Он используется в операторах объявления в теле функций, а также внутри блоков операторов. Объекты, имена которых объявляются со спецификатором auto, размещаются в локальной памяти непосредственно перед началом выполнения функции или блока операторов. При выходе из блока или при возвращении из функции (о механизмах вызова функций и возвращения из них речь ещё впереди), соответствующая область локальной памяти освобождается и все ранее размещённые в ней объекты уничтожаются. Таким образом спецификатор влияет на время жизни объекта (это время локально). Спецификатор auto используется редко, поскольку все объекты, определяемые непосредственно в теле функции или в блоке операторов и так по умолчанию располагаются в локальной памяти. Вне блоков и функций этот спецификатор не используется. • register. Ещё один спецификатор автоматического класса памяти. Применяется к объектам, по умолчанию располагаемым в локальной памяти. Представляет из себя "ненавязчивую просьбу" к транслятору (если это возможно) о размещении значений объектов, объявленных со спецификатором register в одном из доступных регистров, а не в локальной памяти. Если по какой-либо причине в момент начала выполнения кода в данном блоке операторов регистры оказываются занятыми, транслятор обеспечивает с этими объектами обращение, как с объектами класса auto. Очевидно, что в этом случае объект располагается в локальной области памяти. • static. Спецификатор внутреннего статического класса памяти. Применяется к именам объектов и функций. В C++ этот спецификатор имеет два значения. Первое означает, что определяемый объект располагается по фиксированному адресу. Тем самым обеспечивается существование объекта с момента его определения до конца выполнения программы. Второе значение означает локальность. Объявленный со спецификатором static объект локален в одном файле (то есть, недоступен из других файлов программы) или в классе (о классах — позже). Статический объект используется в объявлениях вне блоков и функций, а также в объявлениях, расположенных в теле функций и в блоках операторов. • extern. Спецификатор внешнего статического класса памяти. Обеспечивает существование объекта с момента его определения до кон37


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

Область действия имени Область действия имени - это часть программы, в пределах которой имя является представителем связанного с ним объявления или объекта. В пределах области действия имени объект, представляемый именем, находится в памяти. В этом случае имя можно использовать (правда, с некоторыми ограниченииями) для доступа к связанному с этим именем объекту. Всё зависит от того, в каком месте программы расположено соответствующее объявление. Различают следующие области действия имени: блок, функция, список объявлений параметров в прототипе функции, файл и класс. • Область действия блок. Блок операторов - это аналог сложого предложения естественного языка. Имеет вид списка операторов, заключённых в фигурные скобки. Область действия имени, которое было введено в программу в блоке операторов, распространяется в этом блоке строго от точки определения данного объекта и до конца блока. Это обстоятельство связано лишь с особенностью работы транслятора. Поскольку в процессе трансляции файл с текстом программы читается последовательно от начала и до конца, использование имени становится возможным лишь после его объявления. • Область действия функция. Определение функции имеет вид блока операторов, которому предшествует заголовок функции. В заголовке функции содержится вся необходимая транслятору информация о функции: спецификация типа возвращаемого значения, её имя, список её параметров (возможно, что пустой). Тело функции составляет блок операторов. А это значит, что основные свойства области действия функции совпадают со свойствами области действия блока. Кроме того, в область действия функции попадают также имена параметров, объявленные в списке объявления параметров функции, а также имя самой функции. В определении функции эти объявления играют роль операторов определения переменных. Создаваемые с их помощью объекты располагаются в специальной области памяти, которая называется областью активации функции и также располагается в сегменте стека. Включение имени функции в область действия функции позволяет организовывать её рекурсивный вызов. 38


• Список объявления параметров в прототипе функции. Прототип функции является объявлением. Для транслятора это источник предварительной информации о характеристиках функции. Прототип строится на основе заголовка функции. В нём указывается спецификация возвращаемого значения, имя функции, список параметров. Список параметров прототипа функции может влиять на начальные значения параметров в определении функции. Для этого в списке параметров прототипа указываются значения соответствующих параметров. В качестве инициализаторов в этом случае могут использоваться выражения, содержащие имена ранее объявленных в этом списке параметров. Поэтому список можно рассматривать как своеобразную область действия имени. Область действия имени в списке объявления параметров прототипа функции распространяется строго от точки объявления параметра до конца списка. Если в инициализирующих выражениях имена параметров не используются, то можно вообще обойтись без указания имён параметров. Между именами параметров в прототипе и в определении функции не существует никакой связи. Они вообще могут не совпадать. На этапе объявления прототипа функции принципиальное значение имеет информация о типе параметров и их начальном значении. • Область действия файл. Эта область распространяется на весь файл, начиная с точки объявления или определения переменной со спецификатором extern, объявления переменной вне функций со спецификатором static (локальная переменная в файле) и без спецификаторов (глобальная переменная), функции, объявления класса. • Область действия класс будет рассмотрена позднее, после обсуждения понятия класса и связанного с ним понятия объект класса. К этой области относится имя класса. Максимальная область действия имени, объявленного со спецификатором static, ограничена пределами файла, содержащего объявление.

Пространство имён С понятием области действия имени связано понятие пространства имени. Пространством имени называется область программы, в пределах которой это имя должно быть уникальным. Различные категории имён имеют различные пространства имён. К их числу относятся: и Пространство имён глобальных объектов. Это пространство образуется множеством образующих программу файлов. Имена глобальных объектов должны быть уникальны среди множества имён глобальных объектов во всех файлах программы. • Пространство имен поименованных операторов (или операторов с меткой) — функция. Имя оператора должно быть уникально в теле функции, в которой метка была введена в программу. —


• Пространство имён структур, классов, объединений и перечислимых типов зависит от контекста, в котором были объявлены структуры, классы, объединения. Если они были объявлены в блоке — это пространство будет составлять блок, если они были объявлены в файле, таковой областью является программа. C++ помещает эти имена в общее пространство имён. • Имена элементов структур, классов, объединений и перечислимых данных должны быть уникальны в пределах определения структуры, класса, объединения и перечислимых данных. При этом в разных структурах, классах, объединениях и перечислимых данных допустимы элементы с одинаковыми именами. Пространством имён для элементов структур, классов, объединений и перечислимых данных элементов являются сами структуры, классы, объединения и перечисления. • Имена переменных и функций, имена пользовательских типов (типов, определённых пользователем — о них также немного позже) должны быть уникальны в области определения: глобальные объекты должны иметь уникальное имя среди всех глобальных объектов и т.д. По крайней мере в реализациях C++, для процессоров использующих сегментированную модель памяти, существует определённая связь между пространством имени и расположением поименованного объекта в конкретном сегменте памяти. В пределах определённого сегмента может находиться лишь один объект с уникальным именем. В противном случае возникли бы проблемы с организацией ссылок на располагаемые в сегменте памяти объекты. Вместе с тем, одни и те же имена могут использоваться при организации ссылок на объекты, располагаемые в разных сегментах памяти. Например, в теле функции можно обращаться как к глобальному объекту, так и к одноимённому локальному объекту, определённому в теле функции. Правда, обращение к одноимённым объектам, расположенным в различных пространствах имён, ограничено специальными правилами. В связи с необходимостью организации специального протокола обращения к одноимённым объектам, располагаемым в различных сегментах памяти, в C++ возникло понятие области видимости.

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


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

Время жизни или время существования объекта Время жизни или время существования объекта характеризуется периодом, на протяжении которого объект присутствует в памяти. Совсем не обязательно, что на всём протяжении жизни объекта к нему можно будет обращаться по имени. Между областью действия имени и временем его жизни нет прямой зависимости. Время жизни объекта зависит от области памяти, в которой располагается данный объект. Область действия имени определяется фрагментом программы, содержащим соответствующий оператор определения. Различают статическое, локальное и динамическое время жизни объекта. Объекты со статическим временем жизни существуют до конца жизненного цикла программы. Эти объекты размещаются в памяти в самом начале процесса выполнения программы. К этим объектам относятся функции, переменные, объявленные со спецификаторами extern и static, a также глобальные переменные. Объекты со статическим временем существования после размещения в сегменте данных по умолчанию инициализируются нулевыми значениями. Объектам с локальным временем существования память выделяется в сегменте стека или регистрах. К этой категории объектов относятся переменные, объявленные в блоках и функциях, возможно, со спецификаторами auto и register. Такие переменные называются автоматическими. Они создаются в процессе выполнения программы при каждом входе в блок или функцию (при передаче управления в блок или функцию) и автоматически уничтожаются сразу после выхода из блока или по возвращении из функции (процесс возвращения из блока и функции ещё будет подробно обсуждаться). Как правило, эти объекты автоматически не инициализиру_


ются (надо полагать, из соображений эффективности). Эти объекты должны быть специально проинициализированы с помощью инициализаторов или операторов присвоения. Объектам с динамическим временем существования память выделяется в сегменте данных. Эти объекты создаются с помощью операции new или функции malloc(), а удаляются из памяти с помощью операции delete или функции free(). Для ссылки на объекты с динамическим временем существования также используются имена. Работа с такими объектами требует особого внимания, поскольку выход за пределы области действия имени может привести к потере связи между объектом и представляющим его именем. Подобная ситуация называется потерей ссылки. В результате потери ссылки в ходе выполнения программы в памяти могут накапливаться безымянные и недоступные объекты, что в результате может привести к её аварийному завершению. Механизмы выделения и освобождения памяти обсудим позже.

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

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


Здесь важен контекст, а точнее место расположения оператора объявления в исходном файле. Именно по контексту транслятор определяет, что объявление на самом деле является определением глобального объекта. В случае определения глобального объекта объявление располагается в файле вне всяких объявлений функций. Как, например, в предлагаемой ниже крошечной программке. Однако сначала одно важное замечание. Каждая программа на C++ должна содержать функцию с именем main. Эта функция предназначается для входа в программу. С неё каждый раз начинается процесс выполнения программы. Мы ещё вернёмся к обсуждению основных свойств этой "стартовой" функции. i n t XXX;

/ / Это о п р е д е л е н и е ,

void main()

{}

То, что перед нами действительно определение, а не объявление, подтверждает следующий завершающийся сообщением об ошибке пример: int XXX;// Это определение. int XXX;// Здесь транслятор сообщает об ошибке. // Повторное определение. void main() {} Объявление объекта является определением, если оно не содержит спецификатор extern или(!) содержит инициализатор. Таким образом, спецификатор extern превращает один из операторов определения в объявление. Объявлений может быть сколько угодно. Определение всегда одно. int XXX;// Это определение. extern int XXX; // Это объявление. Его расположение не принципиально. void main() { extern int XXX; /* Это лишь уведомление о возможном существовании объекта. Оно может располагаться в любом месте программы. Даже в другом файле. */ > Инициализация превращает любое объявление (даже со спецификатором extern) в определение: 43"


extern int XXX = 100;// Это определение. void main() { extern int XXX; // Это объявление. } В теле функции определение с extern спецификатором не допускается: e x t e r n i n t XXX;// Это о б ъ я в л е н и е . void main() { e x t e r n i n t XXX = 1 0 0 ; / / Ошибка. Глобальный о б ъ е к т н е может // о п р е д е л я т ь с я в б л о к е . } Порядок следования определений и объявлений в области действия файл не принципиален. Надо лишь помнить, что оператор со спецификатором extern и без инициализатора всегда воспринимается транслятором как объявление: extern int XXX;// Это объявление, int XXX; // Это определение, extern int XXX;// Это объявление. void main() { e x t e r n i n t XXX;// Это о б ъ я в л е н и е . } e x t e r n i n t XXX;// Это о б ъ я в л е н и е . В следующем примере самый последний оператор со спецификатором extern и инициализатором воспринимается как определение. Со всеми вытекающими последствиями: e x t e r n i n t XXX;// Это о б ъ я в л е н и е , e x t e r n i n t XXX;// Это о б ъ я в л е н и е . void main() { extern int XXX;// Это объявление. } extern int XXX = 121;// Это определение. В файле, который является главным для данного глобального объекта, оператор определения константы обеспечивает размещение объекта 44


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

Выражение. Символы операций Выражение является частью оператора. Выражения состоят из операндов и символов операций. Символы операций задают способы вычисления выражений. Операнды в выражениях представляются литералами и выражениями. Выражение имеет собственное значение, которое вычисляется в соответствии с определениями операций на основе значений операндов. Выражения могут быть сколь угодно сложной структуры. Порядок вычисления значения выражения определяется целым рядом обстоятельств. К их числу относится приоритет используемых в выражении операций, правила группирования операций, задаваемых системой скобок — разделителей, которые группируют элементы выражения и тем самым дополнительно влияют на порядок вычисления значений. Символы операций C++, за исключением символов [ ], ( ) и ?=, распознаются транслятором как отдельные лексемы. Одна и та же лексема в зависимости от контекста может обозначать различные операции.

Выражение и 1-выражение Доступ к объектам и функциям обеспечивается выражениями, которые в этом случае ссылаются на объекты. Выражение, которое обеспечивает ссылку на константу, переменную или функцию, называется 1-выражением. Имя объекта в C++ является частным случаем 1-выражения. В C++ допускается изменение значений переменных. Значения констант и функций в C++ изменению не подлежат. 1-выражение называется модифицируемым 1-выражением, либо леводопустимым выражением, ес_


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

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

Сведения об аэтОвявлении Здесь мы введём ещё одну категорию объявления. Это так называемое аэтОбъявление. Один из вариантов объявления, обеспечивающего передачу информации встроенному в систему программирования Borland 4.5 ассемблеру. Тем самым обеспечивается выполнение ассемблерного кода. Таким образом, программист получает возможность обращения к ассемблеру. При этом настоятельно рекомендуется знание языка ассемблера. Структура ассемблерных инструкций здесь не отражена. Объявление : := азглОбъявление автОбъявление ::= asm {СтрокаЛексем} СтрокаЛексем ::= Лексема ::= СтрокаЛексем Лексема Строка лексем представляет собой встраиваемый в программу ассемблерный код. Следующий пример (его следует компилировать как DOS приложение) обеспечивает вывод строки с помощью функции DOS. 46


char message[] = "Hello, world!$"; void main() { asm { mov ah,9 // Задание функции DOS с номером 9. mov dx, OFFSET message // Адресация строки. int 0x21 // Вызов функции DOS через прерывание 21.

Перечень операций Этот раздел содержит краткую предварительную информацию об операциях C++. Детальное описание большинства операций на этом этапе ещё невозможно. Графическое представление, название общее описание операции - сейчас этого вполне достаточно. Всё ещё впереди... 1. Унарные 1.1. Адресные

операции операции

& Операция получения адреса операнда. Операндом может быть любое l-выражение. Операция возвращает адрес объекта или функции, на который ссылается операнд. Операция невыполнима по отношению к объектам, определённым со спецификатором register, поскольку существует вероятность того, что они не располагаются в памяти и не имеют определённого адреса. Операция обращения по адресу или операция косвенного обращения. Операндом может быть выражение, значением которого является адрес. Операция косвенного обращения называется также операцией разыменования, поскольку позволяет обращаться к объекту не употребляя при этом имени объекта. 1.2. Операции преобразования

знака

Операция унарный минус. Изменяет знак числа. Операндом может быть любое выражение со значением арифметического(!) типа. Операция преобразует положительное значение в отрицательное значение и наоборот. Тип результата совпадает с типом операнда.

47


+ Операция унарный плюс. Операндом может быть любое выражение со значением арифметического типа. В некоторых источниках её существование объясняется тем, что она ведена для симметрии с унарным минусом. Дело в том, что в C++ существует возможность присвоения (мы ещё уделим внимание этой интуитивно понятной операции) переменной отрицательного значения. Операция унарный плюс позволяет особо подчеркнуть факт присвоения переменной положительного значения. Для этого в C++ и была реализована специальная операция унарный плюс. В формальном языке каждая лексема имеет своё название и назначение. И этот самый плюс-украшение также является операцией. А дальше — рушится иллюзия симметрии унарных операций. Унарный минус работает. Он эквивалентен умножению значения операнда на - 1 . Унарный плюс эквивалентен умножению значения операнда на +1. Операция унарный плюс в буквальном смысле ничего не делает. Тип результата совпадает с типом операнда. 1.3. Унарная побитовая

операция

~ Операция инвертирования или побитового отрицания. Операндом может быть любое выражение целочисленного типа. Операция обеспечивает побитовое инвертирование двоичного кода. 1.4. Унарная логическая

операция

! Операция логического отрицания. Операндом может быть любое выражение со значением арифметического типа. Для непосредственного обозначения логических значений в C++ используются значения false — ложь и true — истина. Кроме того, в логических операциях любое ненулевое значение операнда ассоциируется с единицей. Поэтому отрицанием нулевого значения является 1, т.е. истина, а отрицанием любого ненулевого значения оказывается 0, т.е. ложь. Таким образом, тип операнда является арифметическим, а тип результата - логическим. 1.5. Операция определения

размера

sizeof Операция определения размера объекта или типа. В C++ различают два варианта этой операции. В первом случае операндом может быть любое l-выражение. Это выражение записывается справа от символа операции. Значением выражения является размер конкретного объекта в байтах. Во втором случае операндом является имя типа. Это выражение записывается в скобках непосредственно за символом операции. Значением выражения является размер конкретного типа данных в байтах. Результатом этой операции является константа типа 48


size_t. Этот производный целочисленный беззнаковый тип определяется конкретной реализацией. 1.6. Операции увеличения

и уменьшения

значения

++ Инкремент, или операция увеличения на единицу. Точнее, на величину, кратную единице, поскольку всё зависит от типа операнда. Тип операнда - арифметический (за исключением логического). Тип результата совпадает с типом операнда. Операция имеет дополнительный эффект - она изменяет значение операнда. Поэтому операндом здесь может быть только леводопустимое выражение арифметического типа, либо типа указателя. Пусть, например, значением операнда является адрес расположенного в памяти объекта типа int. Тогда в результате выполнения этой операции значение операнда увеличится на количество байт, соответствующее размерам области памяти, занимаемое объектами типа int в данной реализации. В C++ различают префиксную и постфиксную операции инкремента. В префиксной форме увеличение значения операнда производится до определения значения выражения. В результате значение выражения и значение операнда совпадают. В постфиксной форме увеличение значения операнда производится после определения значения выражения. Поэтому значение выражения оказывается меньше значения операнда. В выражении с префиксной операцией увеличения знак ++ записывается слева от операнда, в выражении с постфиксной операцией — справа. Операция инкремента по отношению к указателю увеличивает значение операнда на количество байт, равное длине одного объекта этого типа, то есть на величину, кратную единице. Операция уменьшения значения операнда на величину, кратную единице (декремент). Эта операция в буквальном смысле симметрична операции инкремента. Сходный побочный эффект, соответствующие ограничения для операнда (свойство леводопустимости, арифметический тип, за исключением логического, либо тип указателя, префиксную и постфиксную формы, изменение значения адреса). В выражении с префиксной операцией увеличения знак — записывается слева от операнда, в выражении с постфиксной операцией — справа. 1.7. Операции динамического

распределения

памяти

new Операция выделения памяти. Позволяет выделить и сделать доступным участок в динамической памяти. В качестве операнда используется имя типа и, возможно, выраже49"


ние инициализатор. Операция возвращает адрес размещённого в памяти объекта. delete Операция освобождения памяти. Освобождает ранее выделенную с помощью операции new область динамической памяти. В качестве операнда используется адрес освобождаемой области памяти. 1.8. Операция

доступа

'.'. Операция доступа. Обеспечивает обращение к именованной глобальной области памяти, находящейся вне области видимости. Эта операция применяется при работе с одноимёнными объектами, расположенными во вложенных областях действия имён. Когда объект во внутренней области действия имени скрывает одноименный объект, областью действия которого является файл. Например: i n t m; / / Глобальная переменная.

int mmm() { int. m; // Локальная переменная. m = 100; // Присвоение значения локальной переменной. ::т = 125; // Присвоение значения глобальной переменной т, // находящейся вне области видимости имени. } Не следует испытывать никаких иллюзий относительно возможностей этой операции. Операция обеспечивает доступ лишь к глобальным, естественно, ранее объявленным объектам, независимо от степени вложенности области действия имени объекта. Поэтому она не обладает свойством транзитивности. Выражения вида ::(::(::т)) воспринимаются транслятором как ошибочные. 2. Бинарные

операции

2.1. Аддитивные

операции

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


Результат сложения указателя с целым числом эквивалентен результату соответствующего количества операций инкремента, применённых к указателю. Тип и значение результата выражения любой бинарной операции определяется в зависимости от принятых в C++ соглашений о преобразовании типов, о которых будет сказано ниже. Этот тип соответствует типу операнда, позволяющего зафиксировать наибольшее число (типу наиболее "длинного" операнда). Операция вычитания. Симметричная по отношению к операции сложения бинарная операция. 2.2. Мультипликативные

операции

Операция умножения. Операндами могут быть выражения арифметического типа. Значением выражения является произведение значений. Тип результата выражения любой бинарной операции определяется в зависимости от принятых в C++ процедур преобразования типов данных. / Операция деления. Операндами могут быть выражения арифметического типа. Значением выражения является частное от деления значения первого операнда на второй операнд. Тип результата выражения любой бинарной операции определяется в зависимости от принятых в C++ процедур преобразования типов данных. % Операция получения остатка от деления целочисленных операндов (деление по модулю). Операндами могут быть выражения интегрального типа, включая и логический (а это означает, что допускаются и такие выражения: 10%true). В процессе выполнения операции операнды приводятся к целому типу. При неотрицательных операндах остаток положительный. В противном случае знак остатка определяется в зависимости от реализации. Тип результата - интегральный. Известно, что для Borland C++ 15%б=3,

<-15)%6=-3, 1 5 % ( - б ) = з ,

(-15)%(-6)=-3.

При ненулевом делителе для целочисленных операндов выполняется соотношение (а/Ь)*Ь+а%Ь=а

51


2.3. Операции сдвига Эти операции определены только для интегральных типов операндов. << Операция левого сдвига. Значением выражения является битовое представление левого операнда, сдвинутое влево на количество разрядов, равное значению правого операнда. При левом сдвиге на i разрядов первые i разрядов левого операнда теряются, последние i разрядов левого операнда заполняются нулями. >> Операция правого сдвига. Значением выражения является битовое представление левого операнда, сдвинутое вправо на количество разрядов, равное значению правого целочисленного операнда. При правом сдвиге на i разрядов первые i разрядов левого операнда заполняются нулями, если левый операнд имеет беззнаковый тип или имеет неотрицательное значение, в противном случае значение определяется реализацией. Последние i разрядов левого операнда теряются.

2.4. Поразрядные операции Поразрядные операции определены только для интегральных типов операндов. Тип их результата совпадает с типом операнда. & Поразрядная конъюнкция битовых представлений значений целочисленных операндов. Значение выражения вычисляется путём побитовых преобразований и зависит от значений соответствующих битов левого и правого операнда. Следующая таблица однозначно определяет операцию поразрядной конъюнкции. Бит левого операнда

Бит правого операнда

1 1 0 0

1 0

1 0

Результат операции &

1 0 0 0

Поразрядная дизъюнкция битовых представлений значений целочисленных операндов. Значение выражения вычисляется путём побитовых преобразований и зависит от значений соответствующих битов левого и правого операнда. Следующая таблица определяет операцию поразрядной дизъюнкции. 52


Бит левого операнда 1 1 0 0

Бит правого операнда 1 0 1 0

Результат операции 1 1 1 0

Поразрядная исключающая дизъюнкция битовых представлений значений целочисленных операндов. Значение выражения вычисляется путём побитовых преобразований и зависит от значений соответствующих битов левого и правого операнда. Следующая таблица определяет операцию поразрядной исключающей дизъюнкции. Бит левого операнда 1 1 0 0

Бит правого операнда 1 0 1 0

Результат операции Л 0 1 1 0

<,<=,>,>=,==,!= Меньше, меньше равно, больше, больше равно, равно, не равно - операции сравнения. Операции сравнения определены на множестве операндов арифметического типа. Допускается также сравнение значений адресов в памяти ЭВМ. Тип результата сравнения - логический. По крайней мере, так сказано в книге Б. Строуструпа "Язык программирования C++". Что же касается конкретных случаев, то многое определяется реализацией. В частности, для Microsoft Visual C++ тип результата - интегральный. Может принимать одно из двух значений: true и false. При этом 0 означает false, a 1 — true. Следующая таблица демонстрирует зависимость результата сравнения от значений операндов Val1 и Val2. Операция < <= > >= == !=

Vah Val1 Val1 Val1 Val1 Val1

true (1), если меньше Val2 меньше или равно Val2 больше Val2 больше или равно Val2 равно Val2 не равно Val2

Val1 Val1 Val1 Val1 VaM Val1

false (0), если больше или равно Val2 больше Val2 меньше или равно Val2 меньше Val2 не равно Val2 равно Val2

2.6. Логические бинарные операции &&,|| И, ИЛИ. Логические бинарные операции определены на арифметических типах операндов. Тип результата при этом — логический. Характеристики операций приведены в следующей таблице 53"


Первый операнд Истина Истина Ложь Ложь

Второй операнд Истина Ложь Истина Ложь

S.& 1 0 0 0

II 1 1 1 0

2.7. Операция присваивания — Простая форма операции присваивания. Значение правого операнда присваевается левому. Операнды могут быть любого типа, но между ними должно быть определено соответствующее присваивание. В качестве правого операнда может выступать любое выражение. Левый операнд должен быть леводопустимым выражением. Результат операции соответствует типу левого операнда. Тип выражения построенного на основе операции присваивания соответствует типу левого операнда. При этом становится очевидным смысл понятия «леводопустимый» - это выражение (в буквальном смысле), которое допустимо располагать слева от знака операции присвоения. В выражении, состоящем из множества выражений, соединенных операциями присвоения: Ап=...=АЗ=А2=А1 ; правый операнд - это тот, который расположен правее всех остальных операндов. В результате выражение оказывается сгруппированным справа налево: Ап= (Ап-1=...= (А3= (А2=А1))...) ; Очевидно, что в таком выражении все операнды, кроме самого правого, должны быть леводопустимыми выражениями. 2.8. Специальные

формы операций

присваивания

В процессе трансляции выражений на этапе генерации кода транслятор строит последовательности машинных кодов, реализующие закодированные в выражениях действия. Например при трансляции выражения А = А + 125 транслятор, прежде всего, генерирует код для вычисления значения выражения А + 125 и присвоения результата переменной А. При этом фрагмент кода, вычисляющий адрес переменной А дважды войдёт во множество команд процессора, реализующих это выражение. 54


В целях упрощения структуры подобных конструкций в C++ применяются комбинированные (или сокращённые) формы операций присваивания. Типы их операндов должны соответствовать операции и допускать присваивание. Левый операнд операций присвоения должен быть леводопустимым выражением. *=

Операция присвоения произведения.

А *= в Присвоение левому операнду произведение значений левого и правого операндов. Операция по своему результату эквивалентна простой форме операции присвоения, у которой правый операнд имеет вид произведения А * В, левый операнд имеет вид А: А = А * в /=

Операция присвоения частного от деления.

А /= В + 254 Присвоение левому операнду частного от деления значения левого операнда на значение выражения правого операнда. Операция по своему результату эквивалентна простой форме операции присвоения, у которой правый операнд имеет вид А /

(В +

254)

левый операнд представляется выражением А. Очевидно, что при этом А должно быть модифицируемым 1-выражением: А = А /

(В +

254)

%= %= в

Операция присвоения остатка отделения.

А

+= += в

Операция присвоения суммы.

А

-= А -= в

Операция присвоения разности.

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

В

55


»= Операция присвоения результата операции побитового сдвига вправо на количество бит, равное значению правого целочисленного операнда. А « =

В

&= Операция присвоения результата поразрядной конъюнкции битовых представлений значений целочисленных операндов. А &= в |= Операция присвоения результата поразрядной дизъюнкции битовых представлений значений целочисленных операндов. А |= в Л

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

выбора

компонентов

структурированного

К операциям выбора компонентов структурированного объекта относятся: Операция прямого выбора (точка). -> Операция косвенного выбора. Об этих операциях будет сказано позже, после определения понятия класса и объекта-представителя класса. 2.10. Операции обращения к компонентам

класса

К операциям обращения к компонентам класса относятся: .* Операция обращения к компоненте класса по имени объекта или ссылки на объект (левый операнд операции) и указателю на компоненту класса (правый операнд операции). ->* Операция обращения к компоненте класса по указателю на объект (левый операнд операции) и указателю на компоненту класса (правый операнд операции).

56


:: Операция доступа к компоненте класса по имени класса и имени компоненты. 2.11. Операция управления процессом вычисления

значений

, Операция запятая. Группирует выражения слева направо. Разделённые запятыми выражения вычисляются последовательно слева направо. Значение и тип выравнивания соответствуют значению и типу самого правого выражения. А &= В,

А * В,

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

функции

() Операция вызова. Бинарная операция, обеспечивающая вызов функции. Левый операнд представляет собой выражение, значением которого является адрес функции. Правый операнд является разделённым запятыми списком выражений, определяющих значения параметров. Значение и тип выражения соответствуют возвращаемому значению функции. 2.13. Операция явного преобразования

типа

() Операция преобразования (или приведения) типа. Эта бинарная операция в контексте так называемого постфиксного выражения и в контексте выражения приведения обеспечивает изменение типа значения выражения, представляемого вторым операндом. Информация о типе, к которому преобразуется значение второго операнда, кодируется первым выражением, которое является спецификатором типа. Существуют две формы операции преобразования типа: каноническая, при которой в скобки заключается первый операнд (в выражениях приведения), и функциональная (в постфиксных выражениях), при которой в скобки заключается второй операнд. При функциональной форме операции преобразования типа спецификатор типа представляется одним идентификатором. Для приввведениия значения к типам, обозначаемым несколькими спецификаторами (например, unsigned long), используется каноническая форма операции. Механизм преобразования типа рассматривается ниже. 2.14. Операция []

индексации Операция индексации. 57


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

операндами

?: Условная операция. Единственная в C++ операция с тремя операндами. Первое выражение-операнд располагается слева от знака ?, второе выражениеоперанд располагается между знаками ? и :, третье выражение-операнд справа от знака :. Выполнение условной операции начинается с вычисления значения самого левого операнда. Если его значение оказывается отличным от 0, то вычисляется значение второго операнда, которое и становится значением выражения. Если значение первого операнда оказывается равным 0, то вычисляется значение третьего операнда, и тогда это значение становится значением выражения. Первый операнд при этом должен быть арифметического типа, второй и третий - любого, в том числе и типа void. Тип результата совпадает с типом второго или третьего операнда. (х < 1 0 ) ? х = 2 5 : х++ f a l s e ? 3.14 : 75 у ? true : vail

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

Выражение Выражение ::= ВыражениеПрисваивания ::= Выражение , ВыражениеПрисваивания В контексте, где запятая выступает в роли разделителя, например, списке параметров вызова функции или в списке инициализации, запятая как знак операции может появиться только в круглых скобках: MyFun(a,

58

(w = 5, w + 9 ) , с)


/* Выражение вызова функции с тремя параметрами. Значение второго параметра задаётся выражением, значение которого равно 14. */ Большая часть выражений представляет собой сочетание символов операций и операндов. Однако это вовсе не означает, что в ходе вычисления значения подобных выражений непременно будут применяться соответствующие операции. Выражение имеет "внешнее" представление и "внутренний" смысл. В каждом конкретном случае всё определяется типом операндов. Если операнды оказываются операндами основного типа, либо указателями, то можно предположить, что при вычислении его значения будет выполняться конкретная операция C++. Если же операнды выражения оказываются операндами производного типа, символ операции может оказаться эквивалентным вызову операторной функции. И кто знает, что делает эта самая операторная функция.

Выражения присваивания ВыражениеПриев айвания ::= УсловноеВыражение УнарноеВыражение ОперацияПрисваивания ВыражекиеПрисваивания ОперацияПрисвайвания ::= = I *= I /= I %- I += I -= I » =

I «=

I S= | л = | | =

Условные и логические выражения УсловноеВыражение ::= ВыражениеИлиЛогическое ВыражениеИлиВключающее ? Выражение : УсловноеВыражение ВыражениеИЛогическое ::= ВыражениеИлиВключающее ::= ВыражениеИЛогическое £& ВыражениеИлиВключающее ВыражениеИлиЛогическое ::= ВыражениеИЛогическое ::= ВыражениеИлиЛогическое I| ВыражениеИЛогическое

Побитовые выражения ВыражениеИлиВключающее ::= ВыражениеИлиИсключающее ::= ВыражениеИлиВключающее | ВыражениеИлиИсключающее ВыражениеИлиИсключающее ::= ВыражениеИ ::= ВыражениеИлиИсключающее

л

ВыражениеИ

ВыражениеИ ::= ВыражениеРавенства ::= ВыражениеИ & ВыражениеРавенства

Выражения равенства ВыражениеРавенства ::= ВыражениеОтношения

59


::= ВыражениеРавенства == ВыражениеОтношения ::= ВыражениеРавенства !• ВьфажениеОтношения

Выражения отношения ВыражениеОтношения ::= ::= ::= ::= ::=

ВыражениеСдвига ВыражениеОтношения ВыражениеОтношения ВыражениеОтношения ВыражениеОтношения

< ВыражениеСдвига > ВыражениеСдвига <= ВиражениеСдвига >= ВыражениеСдвига

Выражения сдвига ВыражениеСдвига ::= АддитивноеВыражение ::= ВыражениеСдвига « АддитивноеВыражение ::= ВыражениеСдвига » АддитивноеВыражение

Аддитивные выражения АддитивноеВыражение ::= МультипликативноеВыражение ::= АддитивноеВыражение + МультипликативноеВыражение ::= АддитивноеВыражение - МультипликативноеВыражение Основные ограничения на типы операндов и особенности выполнения соответствующих операций также ранее уже обсуждались.

Мультипликативные выражения МультипликативноеВыражение ::= ртВыражение ::= МультипликативноеВыражение * ртВыражение ::= МультипликативноеВыражение / ртВыражение ::= МультипликативноеВыражение % ртВыражение Основные ограничения на типы операндов и особенности выполнения операций ранее уже обсуждались.

Выражения с указателями ртВыражение ::= ВыражениеПриведения ::= ртВыражение .* ВыражениеПриведения ::= ртВыражение ->* ВыражениеПриведения

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

60

:= УнарноеВыражение := (ИмяТипа) ВыражениеПриведения


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

Унарное выражение УнарноеВыражение

ПостфиксноеВыражение ++ УнарноеВыражение — УнарноеВыражение УнарнаяОперация ВыражениеПриведения sizeof УнарноеВыражение sizeof (ИмяТипа) ВыражениеРазмещения ВыражениеОсвобождения

УнарнаяОперация :

Унарные выражения группируются справа налево. Вторая и третья БНФ являются основой для построения префиксных выражений увеличения и уменьшения (инкремента и декремента). Символ операции в выражении инкремента и декремента вовсе не означает, что в ходе вычисления значения выражения к операндам будут применяться операции уменьшения и увеличения. В сочетании с операндами производных типов определение значений этих выражений сопровождается вызовами специальных (операторных) функций. В выражениях, представленных четвёртой БНФ, унарная операция * является операцией разыменования. Типом выражения приведения является указатель на объект типа X, а это указывает на то, что описываемое значение является 1-выражением. Значением выражения является значение размещённого в памяти объекта. Если типом операнда является тип указатель на объект типа X, то типом выражения является непосредственно тип X. Результатом операции & является адрес объекта, представленного операндом. При этом операнд операции может оказаться либо Iвыражением, либо квалифицированным именем. Но об этом позже. Далее приводится множество БНФ, определяющих синтаксис выражений размещения и освобождения. У этих выражений достаточно сложная семантика. Детально разобрать их в данный момент мы пока просто не сможем. На этом этапе придётся ограничиться лишь самыми необходимыми сведениями.

61


Выражение размещения ВыражениеРазмещения ::= [::] new /'Размещение] ИмяТипаЫеуг [ИнициализаторИе™./ : := [: : ] new /'Размещение] (ИмяТипа) [ИнициaлизaтopN6w] Размещение ::=

(СписокВыражений)

ИмяТипаЫеы ::= СписокСпецификаторовТипа [OrmcaTenbNew] ::= ::= ИмяТипа

* [СписокСУОписателей] [0пиca1'eльNew] ( O п и c a т e л ь N e w ] [Выражение]

: : = СписокСпецификаторовТипа

СписокСпецификаторовТипа : : = СпецификаторТипа СпецификаторТипа

[АбстрактныйОписатель]

[СписокСпецификаторовТипа]

: : = ИмяПростогоТипа ::= const ::= volatile ;:= *****

Существуют также спецификаторы типа, обозначаемые нетерминальными символами СпецификаторКласса, СпецификаторПеречисления и УточнённыйСпецификаторТипа: СпецификаторТипа

:= СпецификаторКласса := СпецификаторПеречисления := УточнённыйСпецификаторТипа

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

ПолноеИмяКласса

::= ПолноеИмяКласса : := Кв алифициров анноеИмяТипа ••= ***** КвалифицированноеИмяКласса :: КвалифицировгшноеИмяКласса

Наконец мы можем описать, что собой представляет квалифицированное имя. Это последовательность имён, разделённых операцией :: (обозначает класс, объявленный внутри другого класса). 62


КвалифицированноеИмя

::= КвалифицированноеИмяКласса

КвалифицированноеИмяКласса

:: Имя

::= ИмяКласса

ИмяКласса::КвалифицированноеИмяКласса КвалифицированноеИмяТипа

::= ОписанноеИмяТипа

ИмяКласса ИмяКласса

:: КвалифицированноеИмяТипа

::= Идентификатор

ОписанноеИмяТипа

::= Идентификатор

Инициализ aTopNew : := (/~СписокИнициализаторов7) СписокИнициализаторов

::= /'СписокИнициализаторов, ] Инициализатор

Нетерминал АбстрактныйОписатель нам известен. Он используется для описания общей структуры объекта в тех случаях, когда имя объекта может быть опущено. Например, в объявлениях. Выражение размещения обеспечивает выполнение действий, в результате которых в динамической памяти создаётся объект определённого типа. При этом отводится память, необходимая для размещения объекта. Сам объект, возможно, инициализируется. После чего возвращается указатель на размещённый в динамической памяти объект. Время жизни такого объекта ограничено временем выполнения программы. При создании динамического массива (множества объектов одного типа, расположенных друг за другом в одной области динамической памяти), значением выражения размещения оказывается значение указатель на первый элемент массива. При этом соответствующий ОписательЫе\« в квадратных скобках должен содержать информацию о размерах выделяемой области памяти. Естественно, выражение в квадратных скобках должно быть выражением целого типа. Никаких других ограничений на это выражение не накладывается. ...new i n t [ 2 5 ] . . . ...new i n t * [ v a i l + v a l 2 ] . . . ...new f l o a t * * [x]...

Выражение освобождения ВыражениеОсвобождения

::= [::] delete ВыражениеПриведения : : = ['•'•] delete [ ] ВыражениеПриведения

63


Это выражение не имеет определённого значения. А значит и о типе выражения мало что можно сказать определённого. Возможно, что оно является выражением типа void. Именно так обозначается специальный тип, который называется также пустым типом. Операция delete работает с динамической памятью. Она способна прочитать скрытую дополнительную информацию о ранее размещённом в динамической памяти с помощью операции new объекте. Поэтому операция delete требует всего одного операнда указателя на объект. Последствия выполнения операции delete над указателем непредсказуемы и ужасны, если он ссылается на объект, который ранее не был размещён с помощью операции new. Гарантируется безопасность действия операции delete над нулевым указателем. Для удаления массивов используется специальный вариант операции с квадратными скобками. Удаление констант считается ошибкой. Она фиксируется на стадии трансляции. Позже мы обсудим назначение разделителя в выражениях освобождения и размещения'::'.

Постфиксное выражение Постфиксное выражение определяется на основе первичного выражения. Соответствующее множество БНФ включает множество разнообразных альтернатив. ПостфиксноеВьгражение := ПервичноеВыражение = ПостфиксноеВыражение [Выражение] = ПостфиксноеВыражение ([СписокВыражений/) = ПостфиксноеВыражение.Имя = ПостфиксноеВыражение->Имя = ПостфиксноеВыражеание++ = ПостфиксноеВыражение-СписокВыражений

:= ВьгражениеПрисваивания := СписокВыражений, ВыражениеПрисваивания

Первичное выражение является частным случаем постфиксного выражения. Вторым в списке возможных альтернатив постфиксных выражений является БНФ, представляющая различные варианты выражений индексации. Это выражение строится из двух выражений - постфиксного (первичного) выражения, за которым следует ещё одно выражение (второй операнд операции индексации), заключённое в квадратные скобки. Обычно первое выражение представляет указатель на объект типа X (пока неважно, какого типа объект), второе выражение является выражением целочисленного типа. Это выражение называется индексом. Следующей альтернативой является БНФ, представляющая выражения вызова. В нём также участвуют два выражения. Первое выражение может быть представлено именем функции, указателем или ссылкой (ча64


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

Первичное выражение Выражение строится на основе операций, объединяющих операнды. Основным элементом выражения является первичное выражение. Первичное выражение - это фактически элементарный строительный блок любого выражения. Следующее множество БНФ определяет синтаксис первичного выражения: ПервичноеВыражение ::= Литерал = Имя = = = = =

(Выражение) this ::ИмяОператорнойФункции ::КвалифицированноеИмя ::Идентификатор

Понятие литерала ранее уже обсуждалось. Нетерминальный символ Имя также определяется с помощью соответствующего множества БНФ: Имя

::= Идентификатор ::= ИмяОператорнойФункции 65"


::= ИмяФункцииПриведения ::= КвалифицированноеИмя ::= ~ИмяКласса Таким образом, квалифицированное имя является одним из вариантов имени. Оба нетерминальных символа, в свою очередь, представляют первичные выражения. В C++ не существует выражений без типа и значения. Даже в том случае, когда говорят, что значение выражения не определено, то имеется в виду случайное значение из диапазона значений соответствующего типа. Понятие имени операторной функции связано с так называемым совместным использованием операций (разные типы данных совместно используют одни и те же символы операций). Совместно используемые операции в C++ служат для обозначения особой категории функций, предназначенных для имитации операций C++. Имя функции приведения и имя класса, которому предшествует специальный символ ~, а также квалифицированное имя непосредственно связаны с понятием класса. Сложность первичного выражения ничем не ограничивается. Заключённое в круглые скобки выражение рассматривается как первичное выражение. Первичное выражение this связано с классом. Оно также имеет собственный тип и значение, в качестве которого выступает указатель на объект-представитель класса. Операция разрешения области видимости ::, за которой следует идентификатор, квалифицированное имя или имя операторной функции, также образуют первичное выражение. Ничего нельзя сказать о том, что находится вне области видимости. Для транслятора это потусторонний мир. И поэтому не случайно в соответствующей форме Бэкуса-Наура после операции разрешения области видимости используется терминальный СИМЕЮЛ Идентификатор, а не Имя. Идентификатор становится именем лишь после соответствующего объявления. В момент трасляции выражения, построенного на основе операции разрешения области видимости нельзя утверждать, что её операнд является именем. Уже из определения первичного выражения видно, что в C++ сложность выражения ничем не ограничивается. Вместе с тем любое правильно построенное выражение может быть успешно распознано и систематизировано. Здесь всё зависит от контекста, а фактически от символа "соединяющей" операнды операции.

Константные выражения КонстантноеВыражение ::= УсловноеВыражение

66


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

Приоритет операций и скобочная структура выражений Вычисление значений в выражениях сложной структуры определяется на основе принятых в C++ соглашениях о приоритетах операций и о порядке вычисления значений подвыражений, связанных операциями с одним и тем же приоритетом. Ниже приводится таблица приоритетов операций и порядка вычисления значения подвыражений, связанных операциями с одинаковым приоритетом. Значение приоритета 1 2 3 4 5 6 7 8 9 10 11 12 13

Операции 0 [] -> ••• • ! ~ + - & * ( ) каноническая и функциональная формы преобразования типа sizeof new delete ++ — * / % мультипликативные бинарные операции + - аддитивные бинарные операции << >>

Порядок вычисления -> «— -» —» -У —>

->

== != &

—>

Л

—»

I

—>

&&

—>

—»

67


14 15 16 17

II ?: = *= *= /= о/0= + - .= « = » =

-> -» <-

Для разных групп операций с одинаковым приоритетом в C++ определён различный порядок выполнения. Вычисление значений выражений сложной структуры, связанных операциями одинакового приоритета можно либо слева направо, либо справа налево. В первом случае порядок вычисления будет обозначаться символом ->, во втором случае - символом <г- . Если один и тот же символ операции встречается в таблице дважды, то в первом случае он должен восприниматься как символ унарной операции. Максимальное значение приоритета операции равняется 1, минимальное- 17.

Стандартные преобразования В C++ элементарные типы разрешается свободно смешивать в выражениях. Подобное разрешение имеет серьезные последствия: в ходе автоматического преобразования типов операндов может произойти искажение значений, ответственность за которые, в конечном счете, возлагаются непосредственно на программиста. Не допускать мелких преобразований позволяет лишь опыт и знание программистом (!) перечня преобразований, часть из которых (непосредственно с элементарными типами) приводится ниже: 1. Непосредственно перед выполнением арифметических операций операнды "коротких" интегральных типов chor, signed chor, unsigned chor, short int, unsigned short int преобразуются в int, либо (если int не может представить все значения исходных типов) в unsigned int. 2. bool преобразуется в int, при этом true становится 1, false становится 0. 3. int преобразуется в любой другой интегральный тип. Преобразование к меньшему типу может привести к потере информации. 4. Допускается взаимное преобразование вещественных типов. 5. Указатели (о них позже), интегральные и вещественные значения могут быть преобразованы к логическому типу. При этом ненулевые значения преобразуются к true, а значения, равные нулю - к false. 6. Вещественные типы преобразуются к целым. При этом дробная часть преобразуемого значения отбрасывается. 7. Целые типы преобразуются к вещественным, при этом корректность преобразования зависит от реализации. 8. Если один из операндов относится к типу Long double, другой также преобразуется к типу long double; в противном случае, если один из операндов относится к типу double, другой также преобразуется к double; в противном случае, если один из операндов относится к типу floot, другой _


также преобразуется к типу floot, наконец, если оба операнда являются операндами интегрального типа, то перед выполнением арифметических операций операнды "коротких" типов преобразуются к операндам длинных типов. 9. Если один из операндов относится к типу unsigned long, другой также преобразуется к типу unsigned long, в противном случае, если один из операндов относится к типу long int, а другой к типу unsigned int, и если при этом long int может представить весь диапазон значений типа unsigned int (это зависит от реализации), то значение типа unsigned int преобразуется к типу unsigned Long int, в противном случае, если иодин из операндов относится к типу Long, другой также преобразуется к типу Long; в противном случае, если один из операндов относится к типу unsigned, другой также преобразуется к типу unsigned; наконец, в противном случае, оба операнда преобразуются к типу int. Преобразования 8 и 9 производятся над операндами бинарных операций с целью приведения их к общему типу, который используется как тип результата. В зависимости от реализации, в ряде случаев преобразования, ведущие к искажению информации, могут быть выявлены при трансляции, однако они не воспринимаются как ошибки.

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

Ограничивают индексы в объявлениях массивов.

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


• Используются для выделения списков элементов при объявлении структур, объединений и классов. При этом в конце оператора объявления структуры, объединения и класса обязателен разделитель ;. • Используется при инициализации массивов и структур при их определении. • Разделяет элементы списков начальных значений, присваиваемых элементам массивов и структур при их инициализации. • Разделяет описания и определения переменных одного типа. • Разделяет выражения в заголовке цикла for. Завершает оператор. В том числе и пустой. • Разделяет метку и помечаемый с её помощью оператор. • Используется при объявлении классов-наследников для отделения имени объявляемого класса от списка классов-предков.

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

• Используется в определениях и объявлениях для отделения описания объекта от списка инициализации. М В объявлении функции отделяет объявление параметра от значения умолчания.

70


2. Операторы C++ Общие сведения об операторах Согласно принятой нами терминологии, любое законченное предложение на языке C++ называется оператором. Рассмотрим множество БНФ, определяющих синтаксис операторов. Оператор ::= = = = = = =

ОператорВыражение Объявление СоставнойОператор ПомеченныйОператор ОператорПерехода ВыбирающийОператор ОператорЦикла

ОператорВыражение : := [ Выражение]; Судя по последней форме Бэкуса-Наура, любое правильно построенное выражение (построенное по правилам грамматики), заканчивающееся точкой с запятой, является оператором C++. Мы уже второй раз сталкиваемся с пустым оператором (достаточно внимательно посмотреть на последнюю форму Бэкуса-Наура). Первый раз мы встретили пустой оператор при анализе объявления. Ничего удивительного. Объявление - это тоже оператор. Пустой оператор имеет особое назначение. Он используется везде, где по правилам синтаксиса обязательно требуются операторы, а по алгоритму раазрабатываемой программы не нужно ни одного. Эти ситуации подробно будут рассмотрены ниже. Оператор объявления мы уже рассмотрели ранее. На очереди - составной оператор. СоставнойОператор ::= {^СписокОператоров]} Что такое список операторов - также уже известно. Судя по последней БНФ, составной оператор (даже пустой) всегда начинается разделителем { и завершается разделителем }. Кроме того, составной оператор может быть абсолютно пустым (между двумя фигурными скобками может вообще ничего не стоять). Так что конструкция U;

71


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

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

: : = cout «

ВыражениеВывода;

: : = Выражение : : = Выражение « ВыражениеВывода : : = МанипуляторВывода

МанипуляторВывода

: : = endl

В контексте оператора вывода имя cout, в соответствии с информацией в специальном (заголовочном) файле iostream.h, представляет объект, который обеспечивает вывод информации в стандартный поток вывода, который обычно ассоциируется с экраном дисплея. Выполнение этого оператора обычно приводит к появлению на экране дисплея значений выражений, располагаемых в операторе вывода правее символа'«'. Манипулятор вывода endl в процессе вывода информации на дисплей обеспечивает переход на новую строку.

Помеченный оператор ПомеченныйОператор ::= Идентификатор : Оператор ::= case КонстантноеВыражение : Оператор ::= default : Оператор Идентификатор, стоящий справа от двоеточия в помеченном операторе называется меткой. Оператор с меткой в программе используется совместно с оператором перехода и о нём речь ещё впереди. Оператор перехода объявляет метку, и это действительно объявление (а не определение), поскольку метка в теле функции может быть объявлена много раз. Областью действия метки является функция. Помеченный оператор использует объявленную метку. _


Поскольку оператор с меткой является также оператором, C++ допускает следующие экзотические конструкции: Label_l:

L a b e l _ 2 : / * ... * / L a b e l _ N : V a l _ l <= V a l _ 2 ;

А вот повторное использование одной и той же метки в теле функции недопустимо. Оно и понятно. Метка определяет точку передачи управления в теле функции. В случае повторного использования одной и той же метки в теле функции возникает неопределённая ситуация, поскольку становится непонятным, в какое место функции должно быть передано управление. Ключевое слово default в отличие от метки-идентификатора не требует объявления. Область действия такой метки ограничивается оператором выбора (о них речь ещё впереди). Оператор выбора - это составной оператор. Метка default в таком операторе должна быть уникальной. Ключевое слово case также используется в качестве метки, но лишь в сочетании с константным выражением. Область действия такой метки также ограничивается оператором выбора. Меток, которые начинаются с ключевого слова case, в операторе выбора может быть сколько угодно, однако значения соответствующих константных выражений в помеченных операторах выбора должны быть уникальными. Между прочим, константные выражения 2*2 и 4 имеют одно и то же значение.

Оператор перехода ОператорПерехода : : = ::= ::= ::=

g o t o Идентификатор ; break ; continue ; r e t u r n [Выражение];

Операторы перехода обеспечивают безусловную передачу управления. Оператор goto действует строго в пределах функции. Он объявляет метку и передаёт управление помеченному оператору. В случае оператора goto мы действительно имеем вариант объявления имени. При этом в пределах функции допускается многократное объявление одного и того же имени (одной и той же метки). В соответствующей БНФ используется терминальный символ Идентификатор, поскольку лишь после объявления идентификатор становится именем. Как можно было ранее заметить, метка помеченного оператора в соответствующей БНФ также представляется идентификатором. Этому обстоятельству существует своё объяснение. Дело в том, что объявление имени с помощью оператора goto имеет свои особенности. Во-первых, имена, вводимые в программу с помощью оператора goto, имеют обособленное пространство имён и не кон_


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

i n t AAA;

/ / Помеченный о п е р а т о р

объявления.

Во-вторых, метка может быть использована в помеченном операторе до её объявления. То есть до того момента, когда идентификатор станет именем. В результате выполнения оператора перехода goto управление передаётся на помеченный оператор, который, возможно, располагается перед оператором goto. Область действия оператора break ограничивается телом оператора цикла или оператора выбора. В результате выполнения этого оператора немедленно прекращается выполнение оператора, содержащего данный оператор break. При этом управление передаётся оператору, непосредственно следующему за завершённым оператором. Оператор continue используется только в теле оператора цикла. В ходе выполнения оператора continue происходят следующие события: • прекращается выполнение тела оператора цикла (блока операторов, содержащего оператор continue) • после этого выполнение данного оператора цикла возобновляется (всё происходит так, будто бы выполнение цикла и не прерывалось вовсе). Действие оператора continue наглядно может быть смоделировано с помощью оператора goto и пустого помеченного оператора. Мы вернёмся к этому вопросу после того, как рассмотрим операторы цикла. Перед тем как приступить к описанию оператора return, определим несколько новых понятий.

Оператор return. Точка вызова и точка возврата Нам уже известна операция вызова функции и синтаксис постфиксного выражения, обеспечивающего ввызов. Можно довольно просто представить внешний вид оператора вызова функции. Это оператор-выражение произвольной сложности, в состав которого входит выражение вызова функции. Любое выражение имеет значение и тип. Значение выражения вычисляется в ходе выполнения соответствующего программного кода. Для каждого выражения существует момент начала вычисления значения. Этот момент характеризуется соответствующими значениями регистров процессора и состоянием памяти компьютера. Это обстоятельство позволяет определить гипотетическую точку начала выполнения выражения. На листинге программы эта точка располагается обычно слева, но она может быть расположена и справа от соответствующего выражения. Расположение этой точки зависит от многих обстоятельств. В том числе, от приоритета выполняемых операций и от порядка вычисления выражения, который зависит от входящей в выражение операции. Мы мо_


жем указать эту точку на листинге программы лишь благодаря тому обстоятельству, что транслятор обеспечивает строгое функциональное соответствие множества команд ассемблера и программного кода. Точка завершения выполнения выражения соответствует моменту завершения вычисления значения и на листинге программы располагается справа или соответственно слева от вычисляемого выражения. В точке завершения становится известно значение выражения. Если выражение является выражением вызова функции, точка завершения выполнения выражения называется точкой возврата из функции. Так вот оператор return немедленно прекращает выполнение операторов в теле функции и передаёт управление в точку возврата. Поскольку вызов функции является выражением, точка возврата имеет значение. Это значение определяется значением выражения, которое обычно располагается непосредственно за оператором возврата return. Тип возвращаемого значения должен соответствовать типу, который указывается спецификатором определения в объявлении и определении функции. Если в качестве спецификатора объявления в определении и объявлении функции используется ключевое слово void, оператор return в теле этой функции используется без выражения. В этом случае выражение вызова функции оказывается выражением типа void, а значение выражения вызова в точке возврата оказывается неопределённым. Такое выражение не может входить в состав выражений более сложной структуры в качестве операнда выражения, поскольку значение всего выражения оказывается неопределённым. Выражение с неопределённым значением (выражение вызова функции типа void) может выступать лишь в качестве выраженияоператора. Главное - это не забыть поставить в конце этого выражения разделитель ';', который и превращает это выражение в оператор.

Выбирающий оператор ВыбирающийОпаратор : : = i f

(Выражение) Оператор

: : = switch

(Выражение)

false Оператор

Оператор)

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


В ходе выполнения условного оператора if вычисляется значение выражения, стоящего в скобках после ключевого слова if. В том случае, если это выражение оказывается не равным нулю, выполняется первый стоящий за условием оператор. Если же значение условия оказывается равным нулю, то управление передаётся оператору, стоящему после ключевого слова else, либо следующему за условным оператором оператору. i f (i) { i n t k = 1;} else { i n t 1 = 10;) Этот пример условного оператора интересен тем, что операторы, выполняемые после проверки условия (значение переменной i), являются операторами объявления. В ходе выполнения одного из этих операторов объявления в памяти создаётся объект типа int с именем к и значением 1, либо объект типа int с именем I и значением 10. Областью действия этих имён являются блоки операторов, заключающих данные операторы объявления. Эти объекты имеют очень короткое время жизни. Сразу после передачи управления за пределы блока эти объекты уничтожаются. Ситуация не меняется, если условный оператор переписывается следующим образом: i f (i) i n t k = 1; else i n t 1 = 10; При этом область действия имён и время жизни объектов остаются прежними. Это позволяет несколько расширить первоначальное определение блока: операторы, входящие в выбирающий оператор также считаются блоком операторов. Подобное обстоятельство являлось причиной стремления запретить использование операторов объявлений в теле условного оператора. В справочном руководстве по C++ Б.Строуструпа по этому поводу сказано, что в случае, если объявление является единственным оператором, то в случае его выполнения возникает имя "с непонятной областью действия". Однако запрещение использования оператора объявления в условном операторе влечёт за собой очень много ещё более непонятных последствий. Именно по этой причине в последних реализациях C++ это ограничение не выполняется. Проблема области действия является проблемой из области семантики языка и не должна оказывать влияния на синтаксис оператора. Выбирающий оператор switch или оператор выбора предназначается для организации выбора из множества различных вариантов. Выражение, стоящее за ключевым словом switch обязательно должно быть выражением целого типа. Транслятор строго следит за этим. Это связано с тем, что в теле оператора могут встречаться помеченные _


операторы с метками, состоящими из ключевого слова case и представленного константным выражением значения. Так вот тип switch-выражения должен совпадать с типом константных выражений меток. Синтаксис выбирающего оператора допускает пустой составной оператор и пустой оператор в качестве операторов, следующих за условием выбирающего оператора: switch (i) ; // Синтаксически правильный оператор выбора, switch (j) (} // Сейчас нам важна лишь корректность оператора, switch (r) i++; // Этот также правильный оператор.

В теле условного оператора в качестве оператора может быть использовано определение: switch (Jc) ( i n t q, w, e ; } Этот оператор выбора содержит определения объектов с именами q, w, e. Туда могут также входить операторы произвольной сложности и конфигурации: switch

(к) { i n t q, w, e ; q = 10; е = 15; w = q + е; }

Входить-то они могут, а вот выполняться в процессе выполнения условного оператора не будут! А вот включение в оператор выбора операторов определений с одновременной инициализацией создаваемого объекта недопустимо. И об этом мы уже говорили. Оно вызывает сообщение об ошибке независимо от того, в каком месте оператора выбора оно располагается: switch

(к) { int q = 100, w = 255, е = 1024; // Ошибка... default: int r = 100; // Опять ошибка... }

Дело в том, что в ходе выполнения оператора объявления с одновременной инициализацией создаваемого объекта происходят два события: во-первых, производится определение переменной, при котором выделяется память под объект соответствующего типа: i n t q ; i n t w; i n t

e;

во-вторых, выполняется дополнительное днйствие — нечто эквивалентное оператору присвоения: q = 100; v = 255; е = 1024; 77"


а вот этого в данном контексте и не разрешается делать! Просто так операторы в теле условного оператора не выполняются. При этом возникает странная ситуация: создание объекта в памяти со случайным значением оказывает на процесс выполнения программы меньшее влияние, нежели создание того же самого объекта с присвоением ему конкретного значения. Казалось, логичнее было бы не делать никаких различий между операторами объявления и прочими операторами. Но дело в том, что оператор выбора состоит из одного единственного блока. И нет иного пути создания объекта с именем, область действия которого распространялась бы на всё тело оператора выбора, как разрешение объявления переменных в любой точке оператора выбора. Судя по всему, переменная создаётся до того момента, как начинают выполняться операторы в блоке. Объявление превыше всего! И всё же, какие операторы выполняются в теле оператора выбора (разумеется, за исключением объявления без инициализации)? Ответ: все подряд, начиная с одного из помеченных. Возможно, что помеченного меткой "default:". При этом в теле оператора выбора может быть не более одной такой метки. switch (vail) default: х++; А возможно, помеченного меткой "case КонстантноеВыражение :". В теле оператора выбора таких операторов может быть сколь угодно много. Главное, чтобы они различались значениями константных выражений. Нам уже известно, что является константным выражением и как вычисляется его значение. Небольшой тест подтверждает факт вычисления значения константного выражения транслятором: switch (x) int t; // Об этом операторе уже говорили... case 1+2: у = 10; case 3: у = 4; t = 100; / / В этом месте транслятор сообщит об ошибке. // А откуда он узнал, что 1+2 == 3 ? // Просто посчитал... default: cout « у « endl;

Рассмотрим схему выполнения оператора switch: • вычисляется выражение в круглых скобках после оператора switch (предварительная стадия); • это значение последовательно сравнивается со значениями константных выражений за метками case (стадия определения начальной точки выполнения оператора); 78


• если значения совпадают, управление передаётся соответствующему помеченному оператору (стадия выполнения); • если ни одно значение не совпадает и в теле оператора case есть оператор, помеченный меткой default, управление передаётся этому оператору (но даже в этом случае сочетание объявления с инициализацией недопустимо!) (стадия выполнения); • если ни одно значение не совпадает, и в теле оператора case нет оператора, помеченного меткой default, управление передаётся оператору, следующему за оператором switch (стадия выполнения). Метки case и default в теле оператора switch используются лишь при начальной проверке, на стадии определения начальной точки выполнения тела оператора. На стадии выполнения все операторы от точки выполнения и до конца тела оператора выполняются независимо от меток, если только какой-нибудь из операторов не передаст управление за пределы оператора выбора. Таким образом, программист сам должен заботиться о выходе из оператора выбора, если это необходимо. Чаще всего для этой цели используется оператор break. В этом разделе нам остаётся обсудить ещё один вопрос. Это вопрос о соответствии оператора выбора и условного оператора. На первый взгляд, оператор выбора легко может быть переписан в виде условного оператора. Рассмотрим в качестве примера следующий оператор выбора: int intXXX;

switch (intXXX) { case 1: int intYYY; /* Здесь инициализация переменной запрещена, однако определение переменной должно выполняться. */ break; case 2: case 3: intYYY = 0; break; )

Казалось бы, этот оператор выбора может быть переписан в виде условного оператора: int intXXX;

if (intXXX == 1) { int intYYY = 0 ; // Здесь допускается инициализация!

J 79


else if (intXXX == 2 |I intXXX == 3) { intYYY = 0; /* Здесь ошибка! Переменная intYYY не объявлялась в этом блоке операторов. */ )

Если в операторе выбора используется локальная переменная, то для всего множества помеченных операторов из блока оператора выбора требуется единственное объявление этой переменной (лишь бы она не инициализировалась). В условном операторе переменная должна объявляться в каждом блоке. Ситуация с необъявленной в одном из блоков условного оператора переменной может быть решена путём создания внешнего блока, в который можно перенести объявления переменных, которые должны использоваться в блоках условного оператора. int intXXX;

/* Этот внешний блок является решением проблемы. В нём ется объявление переменной intYYY. */ { int intYYY = 0;

располага-

if (intXXX == 1) { intYYY = 0; } else if (intXXX == 2 I I intXXX == 3) < intYYY = 0; ) }

Нам удалось преодолеть проблемы, связанные с областями действия, пространствами и областями видимости имён путём построения сложной системы вложенных блоков операторов. Простой одноблочный оператор выбора, содержащий N помеченных операторов, моделируется с помощью N+1 блока условных операторов. Однако каждый оператор хорош на своём месте.

Операторы цикла Операторы цикла задают многократное исполнение. ОператорЦикла ::= while (Выражение) Оператор ::= for (ОператорИнициализaipotFor [Выражение] ; (Выражение./ ) Оператор _


: : = d o Оператор w h i l e

(Выражение);

ОператорИнициализацииЕог : : = Операторвыражение : : = Объявление Операторы, начинающиеся ключевыми словами for и while, называются операторами цикла с предусловием. Следует также обратить внимание на точку с запятой между двумя выражениями цикла for. В последнем примере они представлены символами ВыражениеА и ВыражениеВ. Перед нами классический пример разделителя. ОператорИнициализацииРог является обязательным элементом заголовка цикла. Обязательный оператор вполне может быть пустым. Рассмотрим пример оператора цикла for: for

( ; ; ) ;

Его заголовок состоит из пустого оператора (ему соответствует первая точка с запятой) и разделителя, который разделяет два пустых выражения. Тело цикла — пустой оператор. Пустое выражение, определяющее условие выполнения цикла for интерпретируется как всегда истинное условие. Отсутствие условия выполнения предполагает безусловное выполнение. Синтаксис C++ накладывает на структуру нетерминального символа ОператорИнициализацииРог жёсткие ограничения: • это всегда единственный оператор, • он не может быть блоком операторов, • единственным средством усложнения его структуры служит операция запятая, которая управляет последовательностью выполнения образующих оператор выражений. Рассмотрим принципы работы этого оператора. Работа цикла for разделяется на четыре этапа. • Прежде всего, выполняется оператор инициализации цикла. Если он не пустой, выражение за выражением, слева направо. Этот этап можно назвать этапом инициализации цикла. Он выполняется один раз, в самом начале работы цикла. • Затем вычисляется значение выражения, которое располагается слева от оператора инициализации. Это выражение называется выражением условия продолжения цикла. Сам этап можно назвать этапом определения условий выполнимости. • Если значение этого выражения отлично от нуля (т.е. истинно), выполняется оператор цикла. Этот этап можно назвать этапом выполнения тела цикла. • После этого вычисляются значения выражений, которые располагаются слева от выражения условия продолжения цикла. Этот этап можно назвать этапом вычисления шага цикла. Цикл for называется пошаговым циклом, поскольку явным образом задаваемый шаг (пусть даже и пустой!) относится к его управляющей части. ш На последних двух этапах могут измениться значения ранее определённых переменных. А потому следующий цикл повторяется с этапа определения условий выполнимости. Оператор инициализации цикла for - это всего лишь название оператора, который располагается в заголовке цикла. Этот оператор может _


инициализировать переменные, если того требует алгоритм. В этот оператор могут входить любые выражения, в конце концов, он может быть пустым. Транслятору важен синтаксис оператора, а не то, как будет выполняться данный оператор цикла. int qwe; for (qwe < 10; ; ) {) // Оператор инициализатор построен / / н а основе выражения сравнения. // Оператор инициализатор на основе for (this; ; ) (} // первичного выражения this. // Оператор инициализатор образован for (qwe; ; ) {} // первичным выражением qwe.

Ещё пример: int i = 0; int j ; int vail • 0; int val2;

25; i*2;

for ( ; i < 100; i++, j — )

i vail = i; val2 - j ; >

Мы имеем оператор цикла for, оператор инициализации которого пуст, а условие выполнения цикла основывается на значении переменной, которая была ранее объявлена и проинициализирована. Заголовок цикла является центром управления цикла. Управление циклом основывается на внешней по отношению к телу цикла информации. Ещё пример: f o r ( i n t i = 25, i n t j = i * 2 ; i < 100; i++, j — ) { vail = i; val2 - j ; } Заголовок нового оператора содержит пару выражений, связанных операцией запятая. Тело оператора представляет всё тот же блок операторов. Что может содержать тело оператора? Любые операторы. Всё, что может называться операторами. От самого простого пустого оператора, до блоков операторов произвольной сложности! Этот блок живёт по своим законам. В нём можно объявлять переменные и константы, а поскольку в нём определена собственная область действия имён, то объявленные в блоке переменные и константы могут скрывать одноимённые объекты с более широкой областью действия имён. _


А вот использование блока в операторе инициализации привело бы к дополнительным трудноразрешимым проблемам с новыми областями действия и видимости имён, вводимых в операторе инициализации. Часть переменных могла бы оказаться невидимой в теле оператора цикла. Операция запятая позволяет в единственном операторе сделать всё то, для чего обычно используется блок операторов. В качестве составных элементов (в буквальном смысле выражений-операторов) этого оператора могут использоваться даже объявления. Таким образом, в заголовке оператора цикла for можно объявлять и определять переменные. Рассмотрим несколько примеров. Так, в ходе выполнения оператора цикла int i; f o r (i = 0; i < 10; i++) { i n t j = 0; j += i ; } десять раз будет выполняться оператор определения переменной j . Каждый раз это будут новые объекты. Каждый раз новой переменной заново будет присваиваться новое значение одной и той же переменной i, объявленной непосредственно перед оператором цикла for. Объявление переменной i можно расположить непосредственно в теле оператора-инициализатора цикла: f o r ( i n t i = 0; i < 10; i++) < i n t j = 0; j += i ; } И здесь возникает одна проблема. Дело в том, что тело оператора цикла for (оператор или блок операторов) имеет ограниченную область действия имён. А область действия имени, объявленного в оператореинициализаторе, оказывается шире этой области. Заголовок цикла for в C++ — центр управления циклом. Здесь следят за внешним миром, за тем, что происходит вне цикла. И потому все обращения к переменным и даже их новые объявления в заголовке цикла относятся к "внешней" области видимости. Следствием такого допущения (его преимущества далеко не очевидны) является правило соотнесения имени, объявленного в заголовке и области его действия. По отношению к объявлению переменной в заголовке оператора цикла for, правило соотнесения гласит, что область действия имени, объявленного в операторе инициализации цикла for, располагается в блоке, содержащем данный оператор цикла for. А вот область действия имени переменной j при этом остаётся прежней. В теле оператора for может быть определена одноимённая переменная: for ( i n t i = 0; i < 10; i++) { i n t i = 0; i += i ; } _


Пространство имени переменной в операторе цикла ограничено блоком из двух операторов. В этом пространстве переменная, объявленная в заголовке, оказывается скрытой одноимённой переменной. Десять раз переменная i из оператора-инициализатора цикла будет заслоняться одноимённой переменной из оператора тела цикла. И всякий раз к нулю будет прибавляться нуль. Ещё один пример. Два расположенных друг за другом оператора цикла for содержат ошибку for (int i = 0, int j = 0; i < 100; i++, j—) { // Операторы первого цикла. ) for (int i = 0, int k = 250; i < 100; i++, Jc-~) { // Операторы второго цикла. ) Всё дело в том, что, согласно правилу соотнесения имён и областей действия имён в операторе цикла for, объявления переменных в заголовке цикла оказываются в общем пространстве имён. А почему, собственно, не приписать переменные, объявленные в заголовке цикла блоку, составляющему тело цикла? У каждого из альтернативных вариантов соотнесения имеются свои достоинства и недостатки. Однако выбор сделан, что неизбежно ведёт к конфликту имён и воспринимается как попытка переобъявления ранее объявленной переменной. Эту самую пару операторов for можно переписать, например, следующим образом: for (int i = 0, int j = 0; i < 100; i++, j—) { // Здесь располагаются операторы первого цикла. } for (i = 0, int k = 250; i < 100; i++, k — ) { // Здесь располагаются операторы второго цикла. 1 Здесь нет ошибок, но при чтении программы может потребоваться дополнительное время для того, чтобы понять, откуда берётся имя для выражения присвоения i = 0 во втором операторе цикла. Кроме того, если предположить, что операторы цикла в данном контексте реализуют независимые шаги какого-либо алгоритма, то почему попытка перемены мест пары абсолютно независимых операторов сопровождается сообщением об ошибке: for (i = 0, int k = 250; i < 100; i++, k—) { // Здесь располагаются операторы второго цикла.

J


for (int i = 0, int j = 0; i < 100; i++,

j—)

// Здесь располагаются операторы первого цикла.

Очевидно, что в первом операторе оказывается необъявленной переменная i. Возможно, что не очень удобно, однако, в противном случае, в центре управления циклом трудно буден следить за внешними событиями. В конце концов, никто не заставляет программиста располагать в операторе инициализации объявления переменных. Исходная пара операторов может быть с успехом переписана следующим образом: i n t i , j , k;

for (i = 0, k = 250; i < 100; i++, k—) { II Здесь располагаются операторы второго цикла. } for (i = 0, j = 0; i < 100; i++, j — ) { // Здесь располагаются операторы первого цикла. )

А вот ещё один довольно странный оператор цикла, в котором, тем не менее, абсолютно корректно соблюдены принципы областей действия имён, областей видимости имён, а также соглашения о соотнесении имён и областей их действия: for

( i n t х ; х < 1 0 ; х++) { i n t х = 0 ;

х++;}

Так что всегда наддо помнить о том, что область действия имён в заголовке цикла шире от области действия имён в теле цикла. И вообще, если можно, лучше избавляться от объявлений в заголовке оператора цикла. Отметим эквивалентные формы операторов цикла с предусловием. Пошаговый оператор цикла for

(ОператорИнициализацииГог

[ВыражениеА]

;/ВыражениеВ])

Оператор

эквивалентен оператору цикла ОператорИнициализацииРог while (ВыражениеА) { Оператор ВыражениеВ ; )

Цикл while отличается системой управления. Его выполнение определяется лишь значением условного выражения. Здесь следует обратить внимание на точку с запятой после выражения, представленного нетерминальным символом ВыражениеВ в теле оператора цикла while. В цикле _


while выражение становится оператором. Важно, что условие продолжения цикла в операторе цикла while опускать нельзя. В крайнем случае, это условие может быть представлено целочисленным ненулевым литералом. Оператор цикла do ... while называется оператором цикла с постусловием. От циклов с предусловием он отличается тем, что сначала выполняется оператор (возможно, составной), а затем проверяется условие выполнения цикла, представленное выражением, располагающимся в скобках после ключевого слова while. В зависимости от значения этого выражения возобновляется выполнение оператора. Таким образом, по крайней мере один раз, гарантируется выполнение оператора цикла. int XXX = 0; do (cout «

XXX «

endl; XXX++;) while (XXX < 0) ;

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

(X)

goto Continue; Continue: ; } do (X) { goto Continue; Continue: ; } while (X) for <

(;;)

goto Continue; Continue: ;

86


3. Указатели и массивы Указатели Объявление указателя очень незначительно отличается от ранее рассмотренных вариантов объявлений. Обычный список спецификаторов объявлений (он не вносит никакой принципиально новой информации в объявление указателя), за которым располагается описатель. Объявление : : = СписокСпецификаторовОбъявления СписокОписателей; Описатель : : = p t r О п е р а ц и я Описатель ; := * fСписокСУОписателей] : : = S fCnncoKCVOnncaTeлей] СписокСУОписателей ::= СУОписатель СписокСУОписателей СУОписатель

::= const ::= volatile

Подобно множеству ранее описанных объектов, множество указателей характеризуется типом, который в объявлении задаётся списком спецификаторов. Объявление указателя отличается наличием так называемой рЛОперации в описателе. Символ рЮперации (в сочетании с необязательными элементами const и volatile) как раз и определяет основную специфику указателя. Значениями указателя являются адреса в памяти, выделяемые под объекты. Указатель фиксирует адреса объектов соответствующего типа. Каждому типу соответствует собственный тип указателя. Для указателей определены операции инкремента и декремента, которые изменяют значение указателя на величину, кратную размеру типа адресуемого объекта. Также для указателей определена операция вычитания. Разность значений адресов пары объектов vail и val2 одного и того же типа type вычисляется в соответствии с соотношением: fivall

- &val2 == l o n g

(fivall -

&val2)/sizeof(type)

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

87


Разность значений адресов пары объектов vail и val2 различных типов typei и type2 может быть вычислена лишь после обязательного явного преобразования объекта одного типа к типу другого объекта: & ( v a i l ) -& ( t y p e l ( v a l 2 ) ) == ( l o n g ) & ( v a i l ) - ( l o n g ) & ( t y p e l ( v a l 2 ) ) sizeof(typel) &(type2(vail))-&(val2)=

i (long) & (type2 ( v a i l ) ) - (long) & (val2)) sizeof(type2)

Разность значений адресов может, хотя бы приблизительно, отобразить "относительное расстояние" (например, расстояние в целых, расстояние в объектах типа float и т.д.) между двумя объектами в памяти. Для указателя на объект определена разность указателя и значения целочисленного типа. Представленная как вариант операции присвоения '=', эта операция эквивалентна многократному применению операции декрементации. Для указателя на объект данных также определена сумма указателя и значения целочисленного типа. В виде операции присвоения '+=', она эквивалентна многократному применению операции инкрементации. Таким образом, в C++ значение адреса объекта типа type можно сложить с величиной X. Это будет означать "перемещение" относительно адреса объекта типа type на расстояние в байтах, равное произведению числа X на длину объекта типа type: fi(varl)

(long)S(varl)

+ X*sizeof(type)

При этом Х может быть отрицательной величиной, и совершенно не важно, что подобное "путешествие" по оперативной памяти с фиксированным шагом осуществляется без учёта структуры расположенных в памяти объектов. В конечном счёте, все байты в областях оперативной памяти равноправны. И это личное дело программиста, как обходиться с информацией в памяти — воспринимать её как однородную последовательность данных однородной структуры, либо как-то иначе. А вот сумма значений адресов лишена всякого смысла, и потому выражения, состоящие из операции сложения и пары операндов со значениями адресов, воспринимаются в C++ как ошибочные: i n t X = 0; int

*рХ = &Х;

Так объявляется и инициализируется указатель на объект типа int. рХ = рХ + рХ; / / Ошибка. рХ = рХ + ( l o n g ) p X ;

88


/* Вот так можно! Однако это уже не сложение адресов. Это перемещение по оперативной памяти на расстояние, равное произведению sizeof(int)*long(pX) байт */ Таким образом, операция сложения оказывается более ограниченной, чем операция вычитания, поскольку при сложении значений адресов лишь один операнд может быть адресом, а второй операнд должен представлять собой целочисленную величину. Над указателями в C++ допускаются следующие операции: • разыменования или косвенного обращения, • явного преобразования типа, • присваивания, • получения адреса, • аддитивные операции (кроме сложения адресов объектов), • инкрементации и декрементации, • сравнения. Ранее уже рассмотренная форма Бэкуса-Наура Описатель

г : = рЬгОперация

Описатель

свидетельствует о возможности построения описателей, содержащих несколько рЮпераций. Например, возможны и такие объявления: int * * ppIntVal; int * * * pppIntVal; Так в C++ объявляются указатели на указатели - объекты, значениями которых являются адреса указателей на объекты (возможно, что на указатели) определённого типа. Объявления и определения указателей на объекты определённого типа подчиняется общим синтаксическим правилам: Об-ъявлениеПеременной : : = Об'ьявлекиеПеременнойТипаУказательНаОб'ъектОсновногоТипа :;— ***** ОбъявлениеПеременнойТипаУказательНаОб'ъектОсновногоТипа : : • f СписокСпецификаторовОб'ъявления ] /"СписокОписателей^ СписокОписателей

: : = ОписательИнициализатор : : = СписокОписателей , ОписательИнициализатор

ОписательИнициализатор Описатель

;

: : = Описатель

[Иктхиализатор]

: : = ptr-Операция Описатель : : = *****

pti-Операция : : = *

fСписокСУОписателей] _.


СписокСУОписателей

СУОписатель

СУОписатель fСписокСУОписателей;

const volatile

Таким образом, объявление объекта типа указатель на объект основного типа отличается от объявления объекта данного типа лишь обязательным описателем, состоящим из символа * (он называется р1гОперацией, хотя в данном контексте является лишь символом — разделителем) и списком (возможно, что пустым) С\/Описателей. Эти ключевые слова уже встречались в контексте объявления в качестве спецификаторов объявления. Рассмотрим примеры объявления и инициализации переменных: c h a r е е = >Х'; /* Определена переменная символьного типа. Переменной присвоен ASCII код символа X. */ char *pcc = &сс; /* Определена переменная типа указатель на объект символьного типа. В качестве значения переменной присвоено значение, возвращаемое операцией взятия адреса. Это адрес переменной е е . */ Поскольку в C++ используются альтернативные формы инициализатора (заключённый в круглые скобки список выражений), последнее объявление указателя может быть переписано: char *pcc(&cc); int *plntval = Null; i n t * p ! n t V a l ( N u l l ) ; // Эквивалентные операторы о б ъ я в л е н и я . В качестве инициализирующего выражения инициализатора указателя могут быть использованы: • явным образом определённые адреса участков памяти, • указатели с определёнными значениями, • арифметические выражения с операндами-указателями, • выражения, позволяющие получить адрес объекта с помощью операции взятия адреса, • специальная константа NULL, которая является гарантией того, что указатель содержит значение, отличное от любого адреса (такой указатель называется пустым указателем), • нулевое значение, или просто нуль, который в процессе трансляции преобразуется к пустому указателю. Таким образом, проинициализировать указатель в C++ можно следующими способами: _


непосредственно присвоить числовое значение — 32 битовое беззнаковое целое число, которое традиционно представляется в шестнадцатеричном формате с обязательным преобразованием типа:

char *pchar; p c h a r = ( c h a r *)ОхЬ8000000; / * По крайней м е р е , д л я в е р с и и B o r l a n d C++ 4 . 5 т а к о й о п е р а тор выполняется корректно. */ •

с помощью операции взятия адреса:

char *pchar, xchar; pchar = fixchar; •

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

c h a r * p c h a r = new c h a r ; •

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

c h a r * p c h a r = new c h a r ( 2 5 ) ; •

путём присвоения значений ранее проинициализированных указателей:

char *pqchar = pchar; Указатель может быть операндом операции разыменования. При этом использование пустого указателя в качестве операнда в ряде случаев вызывает ошибку выполнения, поскольку операция разыменования пытается определить значение объекта по несуществующему адресу. Поскольку в сочетании с операцией разыменования указатели обеспечивают доступ к объектам, они могут заменять имена объектов. Таким образом, проинициализированный указатель становится своего рода псевдонимом объекта. Более того, некоторые объекты не имеют собственных имён и доступ к таким объектам возможен только по указателю. Унарное выражение с операцией разыменования может использоваться везде, где может быть использовано имя объекта соответствующего типа. Так что с помощью операции разыменования в C++ можно изменять значения переменных. При этом указатель должен быть проинициализирован. Исполь_


зование неинициализированного указателя в выражениях по своим последствиям непредсказуемо. Существует принципиальная возможность присвоения указателю на объект одного типа адрес объекта другого типа. При этом используется операция явного преобразования типа. Явное преобразование типа нам уже не раз встречалось в этом разделе. Надо очень хорошо подумать, где можно применить результаты этого легкомысленного поступка... f l o a t fF = 100.25; int *pil = (int *)(SfF); / * Настроили у к а з а т е л ь н а о б ъ е к т т и п а i n t н а переменную т и па f l o a t . */ cout «

*pil «

endl;

/ / -32768 - в о т к а к о й результат...

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

0 10000101

Л

100 1000 1000 0000

0000

0000

t

мантисса экспонента знак Значение переменной в IEEE формате. Операция разыменования через указатель на переменную типа int обеспечивает возможность просмотра лишь двух байтов четырёхбайтового объекта типа float. Ни о каком IEEE формате здесь речи не может быть. Операция явного преобразования типа диктует новые правила интерпретации значений. cout «

*(float

*)pil «

endl;

А вот так (опять же, с помощью операции явного преобразования типа) через указатель на переменную типа int можно "рассмотреть" всю переменную типа float целиком.

Указатели на константу и константные указатели При объявлении и определении указателя, как сам указатель, так и его значение могут быть объявлены как константы. Для этого в C++ используются спецификатор объявления и описатель const. Объявление указателя на константу должно содержать спецификатор const. Его расположение относительно прочих спецификаторов не_


принципиально. Грамматика C++ не регламентирует порядок следования спецификаторов в списке спецификаторов объявления: char const * pcVal; /* Так объявляется указатель на константу. */ const char * pcVal; /* Указатель может быть объявлен и так.*/

Подобным образом определённому указателю можно присвоить в качестве значения адрес объекта соответствующего типа. Возможна также инициализация указателя адресом объекта какого либо другого типа, но при этом необходимо использовать операцию явного преобразования типа. Указатель на константу можно явным образом перенастроить на другой объект. Например, его значение (это адрес объекта) может быть изменено с помощью операций инкрементации и декрементации. int intVal_l = 10; int intVal_2 = 0; int const * pintval; // Здесь используется спецификатор const. pintVal = Sintval_l; pintVal = Sintval_2; pintVal++; pintVal—; // Допустимые варианты модификации значения указателя.

Сам по себе указатель на константу константой не является. Его назначение — "сохранение" значение адресуемого объекта (пусть даже этот объект является переменной). Поэтому использование значения указателя в качестве операнда операции разыменования с целью изменения значения адресуемого объекта недопустимо. Операторы с подобными выражениями распознаются как ошибочные: intVal = 100; /* Подобное изменение значения вполне корректно.

*/

/* Все действия по изменению значения адресуемого объекта с использованием указателя на константу запрещены! */ (*pintVal) = 101; (*pintVal)++;

При объявлении и определении константного указателя используется описатель const. В операторе определения константного указателя описатель const располагается в списке описателей, правее символа *: int ZZZ = 100; int YYY; int * const pConstlntVal = &ZZZ; // Здесь используется описатель

const.

93


Константный указатель подобен обычной константе: для его объявления требуется спецификатор extern, его определение сопровождается обязательной немедленной инициализацией. Это константа, значением является адрес объекта. В тексте программы невозможно явно "перенастроить" этот указатель, но зато с помощью константного указателя можно изменять значение объекта, на который настроен этот указатель. pConstIntVal++; pConstlntVal = SZZZ;

// Так делать нельзя. Это константа.

•pConstlntVal = 101; // А так делать можно. *pConstIntVal++; // И так тоже можно! /* И в любом случае изменяется значение переменной. Константный указатель не сохраняет значение адресуемого объекта. */

В C++ можно объявить и определить константный указатель на константу. Ключевое слово const в таком объявлении будет выступать одновременно как спецификатор объявления и как описатель: int const * const pCCIntVal = &ZZZ; /* Определён и проинициализирован константный указатель на константу. */

Очевидно, что явная перенастройка и изменение значения адресуемого объекта здесь невозможна. Спецификатор и описатель не позволяют делать это. Обычные указатели могут быть проинициализированы адресами констант. При этом необходимо использовать операцию явного преобразования типа. При настройке обычного указателя на константу требуется явное подтверждение действий программиста. int * pint; int const ddd = 0; int X = 10; pint = &X; •pint = 100;

// Всё нормально.

pint = (int *)£ddd;

Указатель pint инициализируется адресом константы ddd. При этом необходимо явное преобразование типа. Требуется подтверждение того факта, что константа перестаёт быть константой. •pint = 100; Таким образом, в C++ система защиты константы может быть достаточно просто преодолена. Теперь можно явно изменять значение константы через обычный указатель. _


В C++ различаются типы int и int const. После присвоения указателю на константу адреса переменной, значение переменной оказывается под защитой транслятора. Явным образом значение переменной через указатель на константу изменить невозможно. Однако сохранность значения константы отслеживается лишь на этапе преобразования текста программы к множеству машинных команд, то есть при трансляции программы. На этапе выполнения программы не существует никаких средств защиты значения константы. После инициализации простого указателя адресом константы, константа фактически перестаёт быть константой. Теперь через обычный указатель можно вполне легально изменять её значения. Явное преобразование типа лишает значение константы какой-либо защиты.

Указатель void * В C++ существует специальный тип указателя, который называется указателем на неопределённый тип. Для объявления такого указателя используется имя типа void в сочетании с описателем, перед которым располагается символ рЮперации *. void *pUndefPointer; С одной стороны, объявленная подобным образом переменная также является объектом определённого типа — типа указатель на объект неопределённого типа. В Borland C++ 4.5 имя UndefPoint действительно ссылается на объект размером в 32 бита со структурой, которая позволяет сохранять адреса. Но, с другой стороны, для объекта типа указатель на объект неопределённого типа отсутствует информация о размерах и внутренней структуре адресуемого участка памяти. Из-за этого не могут быть определены какие-либо операции для преобразования значений. Поэтому переменной UndefPoint невозможно присвоить никаких значений без явного преобразования этих значений к определённому типу указателя. pUndefPointer = 0хЬ8000000; // Такое присвоение недопустимо.

Подобный запрет является вынужденной мерой предосторожности. Если разрешить такое присвоение, то неизвестно, как поступать в случае, когда потребуется изменить значение переменной UndefPoint, например, с помощью операции инкрементации. pUndefPointex++; // Для типа void * нет такой операции...

Эта операция (как и любая другая для типа указатель на объект неопределённого типа) не определена. И для того, чтобы не разбираться со __


всеми операциями по отдельности, лучше пресечь подобные недоразумения "в корне", то есть на стадии присвоения значения. Объектам типа указатель на объект неопределённого типа в качестве значений разрешается присваивать значения лишь в сочетании с операцией явного преобразования типа. В этом случае указатель на объект неопределённого типа становится обычным указателем на объект какого-либо конкретного типа. Со всеми вытекающими отсюда последствиями. Но и тогда надо постоянно напоминать транслятору о том типе данных, который в данный момент представляется указателем на объект неопределённого типа: int mmm = 10; pUndefPointer = (int *)6mmm; // pUndefPointer выступает в роли указателя на объект типа int. (*(int *)pUndefPointer)++; // Через указатель изменили значение mmm. Cont « * (int*) pUnderPointer « endl;

Специфика преобразования:

указателя

позволяет

выполнить

и

такие

(*(char*) pUnderPointer)++;

Указатель на объект неопределенного типа невозможно перенастроить на другой объект с помощью операции инкрементации. В операторе, реализующем операции инкрементации и декрементации, только с помощью операций явного преобразования типа можно сообщить транслятору величину, на которую требуется изменить первоначальное значение указателя. pUndefPointer++; // Это неверно, инкрементация не определена... (int *)pUndefPointer++; // И так тоже ничего не получается...

Перенастроить указатель на следующий объект типа int можно следующим способом: pUndefPointer = (int *)pUndefPointer + sizeof(int);

Или так: pUndefPointer = (int *)pUndefPointer + 1;

А теперь указатель перенастроился на объект типа char. To есть просто сдвинулся на один байт. pUndefPointer = (char *)pUndefPointer + 1;

96


Работа с указателями на объекты определённого типа не требует такого педантичного напоминания о типе объектов, на которые настроен указатель. Транслятор об этом не забывает. int * pint; int пиши = 10; pint = £mmm; // Настроили указатель. plnt++; // Перешли к очередному объекту. *plnt++;

// Изменили значение объекта, идущего следом за // переменной пиша.

Напомним, что происходит в ходе выполнения этого оператора. • после выполнения операции разыменования вычисляется значение (адрес объекта mmm), • это значение становится значением выражения, • после чего это значение увеличивается на величину, кратную размеру того типа данного, для которого был объявлен указатель. Операции явного преобразования типов позволяют присваивать указателям в качестве значений адреса объектов типов, отличных от того типа объектов, для которого был объявлен указатель: int mmm = 10; char ccc = 'X'; float fff = 123.45; pint = Sramm; pNullInt = (int *)&ccc; pNullInt = (int *)Sfff; /* Здесь, возможно, будет выдано предупреждение разовании. */

об опасном преоб-

Это обстоятельство имеет определённые последствия, которые связаны с тем, что все преобразования над значениями указателей будут производиться без учёта особенностей структуры тех объектов, на которые указатель в самом начале был настроен. При этом ответственность за результаты подобных преобразований возлагается на программиста. Указателю типа void* можно присваивать указатель на любой не являющийся константой тип. Он используется всякий раз, когда неизвестен тип объекта. int ival; int *p_iVal = 0; char *p_chVal = 0; void *p_Val; const int *pc_iVal = SiVal; _


p_Val p Val

p_ival; p chVal;

p_Val = pc_iVal; //Ошибка: pc_iVal - указатель на константу. const void *pcVal = pc_iVal; /* А здесь всё хорошо! Указателю на константу присвоен на константу. */

указатель

Ссылка. Иллюзия имени Ссылка является вариантом указателя. Она используется в качестве альтернативного имени объекта. При объявлении ссылки в описателе используется символ р^Операции &, возможно, в сочетании с; CVOnncателями const и volatile (соответствующая БНФ приводилась в разделе, посвященном синтаксису указателя). Создание второго имени для объекта в C++ связано с определением и последующей инициализацией указателя на объект. Имя обеспечивает доступ к объекту, проинициализированный указатель также указывает на объект. Таким образом, имя указателя становится псевдонимом объекта. Но одно дело работать с именем, другое — с псевдонимом-указателем. Работа с объектами через указатели требует явного использования операций взятия адреса и косвенного обращения (разыменования). Это не всегда удобно. Каждое обращение к объекту по указателю требует разыменования. Сколько раз обращаемся к объекту, столько раз используем эту самую операцию. Ссылка, по сути, является указателем. Но, при этом она обеспечивает иллюзию непосредственной работы с объектом, а не с адресом этого объекта. При трансляции ссылка заменяется указателем, и код программы модифицируется с учётом специфики работы с указателями. Рассмотрим пример. int iVal = 998; int* PiVal = SiVal; /* Определяем и инициализируем объект, а затем и указатель на этот объект. Значением указателя PiVal является адрес переменной iVal. Здесь Явным образом применяется операция взятия адреса при настройке указателя. */ intS RiVal = iVal; /* А вот теперь определили и проинициализировали ссылку. Это тот же самый указатель. Значением ссылки Rival является адрес переменной iVal. Тот, да не тот! Нет здесь явной инициализации указателя, нет здесь операции взятия адреса. Здесь пишется одно, а предполагается совсем другое. При работе со ссылками самое время ознакомиться с приложением, озаглавленным "Прочие характеристики языков программирования". */

98


iVal = iVal + 1; *PiVal = *PiVal + 1; *PiVal++; Rival = Rival + 1; RiVal++; /* Работа со ссылкой, как и работа с именем, не требует явного применения операции разыменования. Транслятор обо всём позаботится сам. */

Главное назначение ссылки — упрощение текста программы путём создания иллюзии непосредственной работы с объектом. Фактически ссылка подобна указателю. Однако при работе со ссылками в C++ действуют ограничения, направленные на поддержание иллюзии: • нельзя изменить значение ссылки после инициализации. Ссылка — это константный указатель. Его нельзя перенастроить. И это понятно. Ссылка должна имитировать имя. А разве можно изменить значение имени (не объекта, а имени, которое на него указывает). • ни одна из операций не действует на ссылку. Все операции воздействуют на объект через ссылку. Полная имитация работы с именем при работе с указателем. • ссылка не может иметь тип void. Указатель может (это универсальный указатель, который может "запомнить" адрес объекта любого типа), а ссылка не может! Опять имитация имени. Не существует объекта типа void, нет и ссылки на void. Ш для ссылки невозможно выделить новый участок памяти, т.е. её невозможно создать с помощью выражения размещения. Имена не создаются (это не указатели). • невозможно создать массив ссылок. И опять всё корректно. Что-то не встречались в C++ массивы имён. То ли дело указатели. Всё-таки объекты. • нет указателей на ссылки. И опять всё правильно. Не бывает указателей на имена. Это не указатели на указатели. Чаще всего ссылки используются при работе с функциями при спецификации параметров и возвращаемого значения. Об этом позже.

Массивы. Синтаксис объявления Рассмотрим новые формы Бэкуса-Наура, которые дополняют уже известные понятия описателя и инициализатора. Объявление _ _ _

: := —


fСписокСпецификаторовОб'ьявления ] [СписокОписателей ] ; Описатель ::= Описатель [Инициализатор] Описатель : :<= Описатель [ [КонстантноеВыражение ] ] ••= ***** Инициализ атор : : = {СписокИнициализаторов [,]} СписокИнициализаторов ::= Выражение : := СписокИнициализаторов, Выражение ::= {СписокИнициализаторов [,]} Теперь мы располагаем набором синтаксических средств для объявления массивов. Массивы не являются основными (встроенными) типами. Они специально объявляются в программе и поэтому могут рассматриваться как производные типы (указатели также относятся к производным типам). Объект типа "массив элементов объявленного типа" представляет последовательность объектов этого самого типа, объединённых одним общим именем. Количество элементов массива является характеристикой конкретного массива, но не самого типа. Приведём примеры объявления и определения массивов. extern i n t intArray_l[]; Объявлен (именно объявлен - об этом говорит спецификатор extern) массив типа int, имя массива — intArray_1, разделители Q указывают на то, что перед нами объявление массива. i n t intArray_2[10] ; А это уже определение массива. Всё тот же тип int, имя массива — intArray, между разделителями { и ] находится константное выражение, значение которого определяет размеры массива. Требование синтаксиса по поводу константного выражения между разделителями в определении массива может быть объяснено лишь тем, что информация о количестве элементов массива требуется до момента начала выполнения программы. int intArray_3[] = (1,2,3); // Это также определение массива. Информация о количестве элементов массива заключается непосредственно в инициализаторе. Элементам массива присваиваются соответствующие значения из списка инициализаторов. Ещё одна форма определения массива: i n t intArray_4[3] = {1,2,3};

100


В этом определении массива важно, чтобы количество элементов в инициализаторе массива не превышало значение константного выражения в описателе массива. В результате выполнения этого оператора в памяти выделяется область, достаточная для размещения трёх объектов-представителей типа int. Участку присваивается имя intArray_4. Элементы инициализируются значениями, входящими в состав инициализатора. Возможна частичная инициализация массива. При этом начальные значения получают первые элементы массива: int intArray_5[3]

= {1,2};

В этом определении массива означены лишь первые два элемента массива. Значение последнего элемента массива в данном случае не определено. Здесь нужно отметить одну особенность синтаксиса инициализатора массива. Речь идёт о необязательной запятой в конце списка инициализаторов. По-видимому, её назначение заключается в том, чтобы указывать на факт частичной инициализации массива. Действительно, последний вариант (частично) инициализирующего оператора определения массива выглядит нагляднее: i n t intArray_5[3] = {1,2, }; Последняя запятая предупреждает о факте частичной инициализации массива. Затраты на связывание запятой в конце списка инициализаторов со строго определённым контекстом частичной инициализации оказываются столь значительными, что последняя запятая традиционно (по крайней мере со времени выхода "Справочного руководства по языку программирования C++") считается всего лишь необязательным элементом любой (в том числе и полной) инициализации. i n t intArray_6[3] = { 1 , 2 , 3 } ; i n t intArray_6[3] = {1,2,3,}; i n t intArray_6{] = {1,2,3}; i n t intArray_6[] = {1,2,3,};

/ / Полная и н и ц и а л и з а ц и я с запятой...

Между этими операторами нет никакой разницы. А вот в таком контексте int intArray_6[3] = {1,2, }; // Частичная инициализация массива из трёх элементов...

Последняя запятая в фигурных скобках - не более как полезное украшение. Что-то недосказанное таится в таком операторе присвоения... i n t intArray_7[];

101


А вот это некорректное объявление. Без спецификатора extern транслятор воспринимает его как ошибку. В скором времени мы обсудим причину этого явления.

Свойства массивов Первое специфическое свойство массивов заключается в том, что определение массива предполагает обязательное указание его размеров. Зафиксировать размер массива можно различными способами (о них мы уже говорили), однако это необходимо сделать непосредственно в момент его объявления, в соответствующем операторе объявления. В программе, состоящей из нескольких файлов, массив определяется в одном из файлов программы. В остальных файлах при объявлении этого массива используется спецификатор extern. Подобное объявление может быть включено и в главный файл. Главное, чтобы транслятор мог различить объявления и собственно определение. В объявлениях со спецификатором extern можно указывать произвольные размеры объявляемого массива (лишь бы они были описаны в виде константного выражения), а можно их и не указывать вовсе - транслятор всё равно их не читает. int intArrayl[10] - {0,1,2,3,4,5,6,7,8,9); extern intArray1[ ] ; extern intArrayl[1000]; /•Казалось бы, если транслятор всё равно не читает значение константного выражения в объявлении, то почему бы там не записать выражение, содержащее переменные? */ int ArrVal = 99; extern intArray1[ArrVal + 1 ] ; /•Однако этого сделать нельзя. ArrVal не константное выражение.*/ Но зато он очень строго следит за попытками повторной инициализации. Второе свойство массивов заключается в том, что объекту типа массив невозможно присвоить никакого другого значения, даже если это значение является массивом аналогичного типа и размерности: char chArray_l[6]; char chArray_2[] = { r q ' , *W ,

v

e'

' };

Попытка использовать оператор присвоения вида chArray_l = chArray_2; вызывает сообщение об ошибке, суть которой сводится к уведомлению, что выражение chArray_1 не является леводопустимым выражением. 102


Следует заметить, что подобным образом ведёт себя и константный указатель, с которым мы познакомились раньше. Он также требует немедленной инициализации (это его единственный шанс получить определён^ ное значение) и не допускает последующего изменения собственного значения. Часто указатель один "знает" место расположения участка памяти, выделенного операциями или функциями распределения памяти. Изменение значения этого указателя приводит к потере ссылки на расположенный в динамической памяти объект. Это означает, что соответствующая область памяти на всё оставшееся время выполнения программы оказывается недоступной. По аналогичной причине невозможна и операция присвоения, операндами которой являются имена массивов. Операторы intArrayl = intArray2; intArrayl[] = intArray2[];

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

intArray_7[]; __


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

Методы навигации Методы навигации по выделенным областям памяти (и просто по памяти), которые обеспечиваются массивами и указателями, очень похожи. Основной способ перемещения от элемента к элементу и доступ к элементам массива произвольного типа обеспечивается операцией индексации. Целочисленное выражение в квадратных скобках является индексом элемента массива. По индексу вычисляется значение смещения относительно первого элемента массива (смещение в байтах равно произведению значения индекса на размер области памяти, занимаемой объектом-представителем данного типа). По смещению происходит позиционирование на требуемый элемент массива (к адресу первого элемента прибавляется значение смещения — полученное значение является адресом требуемого элемента). При этом никаких изменений значения начального адреса не происходит (это константный указатель). Очевидно, что первый элемент массива всегда имеет нулевой индекс. Нет никаких ограничений на знак индекса. Отрицательная величина смещения не противоречит принципам адресной арифметики. Следующий программный код (его результаты, разумеется, зависят от реализации) демонстрирует этот факт и заодно выявляет особенности расположения массивов в памяти: #include

<iostream.h>

// Определение и инициализация глобальных массивов. int intArrayl[5]= {11,22,33,}; int intArray2[] = {0,1,2,3,4}; int intArray3[5]= {10,20,30,40,50}; void main () ( // Определение и инициализация локальных массивов. int intArrayl[5]= {11,22,33,}; int intArray2[] = {0,1,2,3,4}; int intArray3[5]= {10,20,30,40,50}; cout «

"Расположение глобальных массивов." «

cout « cout «

::intArray2[-4] « endl; ::intArray2[5] « endl;

cout «

"Расположение локальных массивов." «

104

endl;

endl;


cout « cout «

intArray2[-4] « endl; intArray2[5] « endl;

В результате выполнения этой программы для Borland C++ 4.5 получаются следующие результаты: Расположение глобальных массивов. 22 10 Расположение локальных массивов. 20 11 Что свидетельствует о разных способах расположения массивов в глобальной и локальной памяти. "Выскочив" в результате имитации ошибок индексирования за пределы массива, мы обнаружили, что, по крайней мере, для Borland C++ 4.5, массивы в локальной памяти располагаются в порядке, обратном объявлению. При этом порядок расположения самих элементов массивов не изменяется. Несмотря на то, что способы размещения массивов в памяти в разных реализациях языка могут различаться, и вообще, последний пример может рассматриваться лишь как МОДЕЛЬ ОШИБКИ ПРОГРАММИРОВАНИЯ, его главный результат заключается в демонстрации того факта, что C++ не располагает средствами контроля за границами массивов. Можно лишний раз убеждиться, что имя массива подобно указателю. Именно этим и объясняется успех выполнения следующего оператора: cout «

*intArray2 «

endl;

Это константный указатель, поэтому любая попытка его изменения обречена на провал: f o r ( i = 0; i < 5; i + + , intArray2++) < endl « *intArray2 « endl; /* Ошибка! Значение указателя intArray2 изменению не подлежит. */ } Не следует путать константный указатель с указателем на константу. Элементы массива доступны любым изменениям. for ( i = 0; i < 5; i++) ( cout « i n t A r r a y 3 [ i ] = i « e n d l ; ) Очевиден способ копирования массивов: это поэлементное копирование значений. Напомним, что операция косвенного обращения или разыменования обеспечивает доступ к объекту по значению указателя, то есть доступ не _ _


по имени, а по адресу. Так вот выражение индексации Е1[Е2] идентично выражению *((Е1)+(Е2)), что означает адрес объекта, представленный значением указателя (или базового адреса), увеличенного (или проиндексированного) на значение индекса. Из тождества двух выражений Е1[Е2] и *((Е1)+(Е2)) следует неожиданное свойство выражения индексации. В силу коммутативности операции сложения в адресной арифметике выражение *((Е1)+(Е2)) эквивалентно выражению *((Е2)+(Е1)), а потому возможен и такой вариант выражения индексации: Е2[Е1]. Таким образом, постфиксное выражение intArray3[i]

в операторе присвоения intArray3[i] = i; можно заменить аддитивным выражением *(intArray3 + i) Это выражение не меняет значения указателя и не вызывает сообщения об ошибке, а оператор присвоения при этом будет выглядеть вот так: * (intArray3 + i )

= i;

В силу закона коммутативности, следующие выражения оказываются эквивалентными: intArray3[i] *(intArray3 + i) *(i + intArray3) i[intArray3] Следовательно, возможен и такой оператор присвоения: i[intArray3] = i;

Массив и константный указатель Несмотря на некоторое сходство с константным указателем, массив является особым типом данных. В этом разделе мы рассмотрим основные отличия массива и константного указателя. Прежде всего, рассмотрим варианты инициализации указателя: char * const pcchVal_l = chArray_2; char * const pcchVal_2 = new char[5]; char * const pcchVal_3 = (char *) malloc(5*sizeof(char));

"106


Для инициализации последнего константного указателя был использован вызов функции malloc(). Каждый из этих операторов демонстрирует один из трёх возможных способов инициализации константного указателя: непосредственное присвоение значения, использование операции new, вызов функции. Операция new и функции распределения памяти, выделяют соответствующие участки памяти и возвращают начальный адрес выделенной области памяти. Ни один из этих способов не подходит для инициализации массива. В свою очередь, при определении константных указателей не используются уже известные инициализаторы массивов с явным указанием размера и списком инициализаторов. Определим массив и константный указатель на область памяти: int intArray[5]= {11,22,33,44,55}; int * const pciVal = new int[5];

К константным указателям и массивам применимы одни и те же методы навигации, связанные с использованием операции индексации: intArray[-25] = 10; *(intArray + 25) • 10; pciVal[2] = 100; •(pciVal + 5) = 100;

А теперь применим операцию sizeof по отношению к проинициализированным указателям: cout «

"pciVal:"« sizeof (poiVal)« " intArray:"« sizeof (intArray) ;

Для Borland C++ 4.5, операция sizeof покажет размер области памяти, занимаемой указателем (4 байта) и размер массива (размер элемента * размерность массива)==(10 байт). Операция sizeof различает указатели и имена массивов. Кроме того, следующий тест также даёт различные результаты. if (intArray == SintArray) cout « "Yes, массив." « endl; else cout « "No, массив." « endl; if (pciVal == SpciVal) cout « "Yes, указатель. " « else cout «

"No, у к а з а т е л ь . " «

endl; endl;

Результат выполнения: Yes - массив; No - указатель. Значение указателя, представляющего массив, совпадает с адресом первого элемента массива. 107


Значением указателя, проинициализированного с помощью выражения размещения, является адрес начала этой области. Сам указатель как объект обладает своим собственным адресом. Интересно, что сравнение значения указателя с результатом выполнения операции взятия адреса не является абсолютно корректным с точки зрения соответствия типов. Операция взятия адреса возвращает лишь определённое значение адреса. И при этом после выполнения этой операции как бы ничего не известно о типе операнда, чей адрес определяли с помощью этой самой операции взятия адреса. Транслятор отслеживает это нарушение принципа соответствия типов и выдаёт предупреждение "Nonportable pointer comparison". Поскольку это всего лишь предупреждение, выполнение процесса трансляции не прерывается и исполняемый модуль, построенный на основе этого программного кода, корректно выполняется. "Успокоить" транслятор можно с помощью операции явного преобразования типа, которая отключает контроль над типами: if (intArray cout « "Yes" else cout « "No";

(int *)SintArray)

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

= "1234567890";

является определением массива типа char, проинициализированным символьной строкой. char * const pStr = "1234567890"; А это было определение константного указателя. Он также инициализируется символьной строкой. А вот такая форма определения символьного массива вызывает сообщение об ошибке: char strArray[10]

108

= "1234567890";


Здесь всё дело в том, что строковый литерал содержит ещё одну литеру — невидимый признак конца строки '\0', так что правильным будет оператор определения следующего вида: char strArray[11]

= "1234567890";

Эквивалентные формы определения символьного массива: c h a r s t r A r r a y [ ] = { Ч ' , ' 2 ' , ' 3 ' , V 4 ' , ' 5 ' , ' 6 ' , ' 7 ' , ( 8 ' , *9' , ' 0 ' , '\0'

};

c h a r s t r A r r a y [11] = { U ' , ' 2 ' , ' 3 ' , М' , ' 5 ' , ' 6 ' , »7> , ' 8 ' , ' 9 ' , ' 0 ' , ' \ 0 ' } ; c h a r s t r A r r a y [ l l ] = { Ч ' , ' 2 ' , ' 3 ' , М' , ' 5 ' , ' 6 ' , ' 7 ' , >8' , Ч 9 ' , v 0 '

,0};

Хотя здесь при инициализации массива строковый литерал не используется, можно утверждать, что strArray является указателем на строку символов. А вот такое определение массива не является символьной строкой, хотя и является символьным массивом: c h a r s t r A r r a y [ 1 0 ] = { Ч ' , ' 2 ' , ' 3 ' , М ' , ' 5 ' , ' 6 ' , 1 7 ' , ' 8 ' , >9' , ' 0 ' ) ;

поскольку здесь нет признака конца строки. Частично проинициализированный символьный массив: c h a r s t r A r r a y [ 1 2 8 ] = ( Ч ' , ' 2 ' , ' 3 ' , М' , ' 5 ' , V 6 ' , >7' , V 8 ' , ' 9 ' , ' 0 ' ,0} ;

Здесь первые одиннадцать символов содержат символьную строку. Значения остальных элементов не определены или равны нулю. Всё зависит от того, в каком сегменте располагается массив. А вот символьная строка, проинициализированная последовательностью строковых литералов. Здесь используется свойство строковых литералов — "соседние" литералы соединяются в один литерал (смотреть всё тот же раздел о литералах). Каждый "соседний" литерал располагается на собственной строке. Инициализация больших символьных массивов становится лёгким и приятным занятием. char strArray[]= "123" "4567" "890"; Разделители между литералами - долой! Множество коротких (в пределах одной строки) строковых литералов участвуют в формировании одной символьной строки. Нам известны отличия определений массива и константного указателя. Этот вопрос уже подробно обсуждался ранее. Рассмотрим несколько операторов C++. 109


char CH[] = "1234567890"; char * const PH = "1234567890"; char * const PH = {"1234567890"}; char * const PH("1234567890") ; Здесь первая строка определяет символьный массив, проинициализированный символьной строкой, следующие три эквивалентных строки являются определением константного указателя, которому в качестве значения присваивается адрес безымянной символьной строки. При этом используются различные формы инициализаторов. Следующая программа подтверждает различие массива и указателя. В этом примере последние два условных оператора демонстрируют способы обращения к массивам и указателям. Только для массива справедливо соотношение Имя == &Имя == &Имя[0] и нет иного способа объявления и определения массивов, как использование разделителей Q для указания, что определяемый или объявляемый объект является массивом. #include <iostream.h> void main() { char CH[] = "0123456789"; char * const PH = "0123456789"; cout «

sizeof(CH) «

"..." «

sizeof(PH) «

endl;

if (CH == (char *)6CH) cout « "Yes. . "; else cout « "No.."; if (PH == (char *)&PH) cout « "..Yes "; else cout « "..No"; if (CH == (char *)&CH && CH == (char *)&CH[0]) cout « "»Yes . . " ; else cout « "»No. .";

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

Tio


выделенную для размещения данного массива. При этом значения элементов массива можно изменять в ходе выполнения программы. Защитить их от изменения можно с помощью дополнительного спецификатора типа const. При этом массив должен быть проинициализирован непосредственно в момент определения: const i n t clntArrayn

= {0,1,2,3,4,5,6,7,8,9};

Это аналог константного указателя на массив констант. Попытки изменения значения элементов массива пресекаются на этапе компиляции. cIntArray[5]

= 111;

//

Ошибка.

А вот от скрытого изменения значения элементы массива констант уберечь не удаётся. const char cCH[] = "0123456789"; char CH[] = "0123456789"; V

СН[15] = X' ; /* Выполнение сСН. */ cout « сСН «

этого

оператора ведёт к изменению

строки

endl;

Транслятор не занимается проверкой корректности выполняемых операций. На этапе выполнения программы язык C++ не предоставляет никаких средств защиты данных.

Многомерный массив Многомерные массивы в C++ рассматриваются как массивы, элементами которых являются массивы. Определение многомерного массива должно содержать информацию о типе, размерности и количестве элементов каждой размерности. int MyArrayl[10]; // Одномерный массив размера 10. int MyArray2[20] [10]; // 20 одномерных массивов размера 10. int MyArray3[30][20][10]; // 30 двумерных массивов размерности 20*10.

По крайней мере, для Borland C++ 4.5, элементы многомерного массива располагаются в памяти в порядке возрастания самого правого индекса, т.е. самый младший адрес имеют элементы МуАггау1[0] , МуАггау2[0][0] , МуАггауЗ[0][0][0], __


затем элементы MyArrayl[l], МуАггау2[0][1], МуАггауЗ[0][0][1] И Т.Д. Многомерный массив подобно одномерному массиву может быть проинициализирован с помощью списка инициализаторов. Первыми инициализируются элементы с самыми маленькими индексами: int

MyArray[3][3][3]

= {0,1,2,3,4,5,6,7,8,9,10,11};

Начальные значения получают следующие элементы трёхмерного массива: МуАггау[0][0][0] МуАггау[0][0][1] МуАггау[0][0][2] МуАггау[0][1][0] МуАггау[0][1][1] МуАггау[0][1][2] МуАггау[0][2][0] МуАггау[0][2][1] МуАггау[0] [2] [2] MyArray[l][0][0] МуАггау[1][0][1] МуАггау[1] [0] [2]

== == == == == == == == == == == ==

0 1 2 3 4 5 6 7 8 9 10 11

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

MyArray[3][3][3]

= { {{0,1}}, {{100},{200,210},{300}},

{{1000},{2000,2100},{3000,3100,3200}}

112


В результате выполнения этого оператора определения будут означены следующие элементы массива МуАггау: МуАггау[О][0][0] МуАггау[0][0][1]

== О == 1

М у А г г а у Ц ] [ 0 ] [ 0 ] == 100 МуАггау[1][1][0] МуАггау[1][1][1]

== 200 == 210

МуАггау[1][2][0]

== 300

МуАггау[2][0][0]

== 1 0 0 0

МуАггау[2][1][0] МуАггау[2][1][1]

== 2 0 0 0 == 2100

МуАггау[2][2][0] МуАггау[2][2][1] МуАггау[2][2][2]

== 3 0 0 0 == 3 1 0 0 == 3200

По аналогии с одномерным массивом, при явной инициализации массива входящего в состав многомерного массива его самая левая размерность может не указываться. Она определяется на основе инициализатора. i n t MyArray[ ] £ 3 ] [ 3 ] = { {{0,1}}, {{100},{200,210},{300}}, {{1000},{2000,2100}} }; Транслятор понимает, что речь идёт об определении массива размерности 3*3*3. А в таком случае int

МуАггау[

][3][3]

= { {{0,1}},

{{100},{200,210},{300}}, {{1000},{2000,2100}}, {{10000}} }; предполагается размерность 4*3*3. В результате МуАггау оказывается массивом из четырёх частично проинициализированных двумерных массивов. Следует помнить, что в C++ нет принципиальной разницы между массивом массивов произвольной размерности и обычным одномерным массивом. Потому и простор для творчества в деле инициализации многомерных массивов ограничивается левым индексом. _ _


Многомерные массивы и указатели Зная обычное расположение элементов массива и то, что первым элементом массива всегда является элемент с нулевыми значениями индексов, можно предположить простой алгоритм доступа к любому элементу многомерного массива. Детали алгоритма рассмотрим на примере организации доступа к элементу массива MyArray[i][j][k], входящего в состав уже известного массива i n t MyArray [NHM] [L] ; / / N,M,L - константы.

Итак, • Выбирается адрес первого элемента массива. Для большей наглядности мы зафиксируем его в виде значения указателя рМуАггау: int *pMyArray = & MyArray[0][0][0];

известно что (&МуАггау[О][О][О] == MyArray), а потому указатель рМуАггау можно проинициализировать и таким способом: i n t *pMyArray = ( i n t *) MyArray; • Для вычисления начального адреса i-ro массива размерности (M*L), входящего в исходный трёхмерный массив, к значению указателя следует добавить смещение, значение которого определяется по формуле i*(M*L)*sizeof(int) • Для вычисления начального адреса j-ой строки (одномерного массива, содержащего L элементов) добавляется смещение, значение которого определяется произведением j*L . Теперь значение смещения составляет (i*(M*L)+j*L)*sizeof(int) • Добавляется смещение для вычисления адреса k-ro элемента строки. Теперь значение смещение составляет (i*(M*L)+j*L+k)*sizeof(int) Сам же адрес оказывается равным сумме начального значения указателя рМуАггау или значению выражения, состоящего из имени массива и значению смещения ((i*(M*L)+j*L+k)*sizeof(int)) И вот к этому адресу и применяется неявным образом операция разыменования, которая обеспечивает доступ к значению элемента массива по его адресу. _ _


А теперь несколько примеров в подтверждение того факта, что в C++ любой многомерный массив на самом деле является одномерным массивом элементов меньшей размерности. (•include < i o s t r e a m . h > v o i d main () i n t Arr[2] [10] = { {1 ,2 ,3 ,4 ,5 ,6 ,7 ,8 ,9 ,10 }, {10,20,30,40,50,60,70,80,90,100} }; // Определяем двумерный массив. int *pArrl • int *pArr2 =

(int *)Arr; &Arr[l][l];

/* Указатели типа int* можно настроить на любой отдельно взятый элемент массива Агг. */ cout « pArr2[0] « endl; /* Можно посмотреть, как выглядит с точки зрения указателя рАхх2 первый элемент массива Агг. */ cout « pArr2[-ll] « endl; /* Здесь всё зависит от позиции наблюдателя... */ /* Объявляем и инициализируем указатели на фрагменты массива Агг. Фактически это массивы указателей.*/ int *pArr3[2] = {Arr[0],}; /* Неполная инициализация массива. */ int *pArr4[2] = {Arr[0], Arr[l]}; /* Полная... */ int *pArr5[] = {Arr[0], Arr[l], Агг[2]}; /* Чрезмерная! Действительную размерность массива Агг здесь проверять некому! Транслятор следит лишь за соответствием типов. рАгг5[2] настроен на несуществующий фрагмент массива Агг. */ cout « pArr5[2][-10] « endl; /* Однако и такой указатель (элемент массива указателей) может быть корректно использован при работе с массивом. */ int *рАггб[2]; рАгг6[0] = Агг[0]; рАгг6[1] = Агг[1]; cout « рАггб[0][3] « endl; /* И ещё один возможный способ объявления, инициализации и использования массива указателей. */

115


4. Препроцессор. Подготовка к трансляции Препроцессор Препроцессор является обязательным компонентом транслятора языка C++. Его назначение - обработка исходного текста программы до начала её трансляции. Перед тем, как начнётся работа по анализу текста программы, препроцессор производит над исходным текстом программы целую серию преобразований. Вот приблизительный (особенности препроцессора определяются реализацией) список преобразований выполняемых препроцессором: • преобразование системно-зависимых обозначений (например, индикаторы конца строки) в последовательности стандартных кодов; • устранение символов, обозначающих переход на новую строку везде, где это обусловлено особенностями системы (например, при объявлении строкового литерала). В результате чего строковый литерал независимо от своей длины оказывается размещённым в одной строке; • конкатенация соседних символьных строк; • удаление комментариев; • замена ESC-последовательностей ('\п', V , '\Ь', ...) эквивалентными числовыми кодами. И это далеко не всё, на что способен препроцессор. Эти преобразования входят в перечень обязательных регламентных работ, выполняемых им в ходе трансляции любого исходного файла. Дополнительные услуги выполняются препроцессором по специальному требованию, которое оформляется в виде директив или инструкций препроцессора. В программе каждая директива препроцессора располагается с новой строки и начинается с символа управления препроцессором #. Рассмотрим список форм Бэкуса-Наура, определяющих синтаксис директив препроцессора. ИнструкцияПрепроцессора ::= # ::= #define Идентификатор СтрокаЛексем ::= #define Идентификтор(СписокИдентификаторов)СтрокаЛексем ::= #include "ИмяФайла" ::= #include <ИмяФайла> ::= #undef Идентификатор УсловноеУправление ::= if4acTb

felse4acrbj endif4acTb ifЧасть

116

: : = # i f КонстантноеВыражение Текст : : = ttifdef Идентификатор Т е к с т


::= #ifndef Идентификатор elifЧасти

Текст

::= #elif КонстантноеВыражение

Текст

::= elif4acTH #elif КонстантноеВыражение else4acTb endifЧасть

Текст

::= #else Текст ::= #endif

СписокИдентификаторов СтрокаЛексем

::= Идентификатор ::= СписокИдентификаторов, ::= Лексема ::= СтрокаЛексем Лексема

Идентификатор

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

Директива препроцессора define Директива define позволяет связать идентификатор (мы будем называть этот идентификатор замещаемой частью) с лексемой (возможно, что пустой!) или последовательностью лексем (строка символов является лексемой, заключённой в двойные кавычки), которую называют строкой замещения или замещающей частью директивы define. Например, #define PI 3.14159 Идентификаторы, которые используют для представления констант, называют объявленными или символическими константами. Например, последовательность символов, располагаемая после объявленной константы PI, объявляет константу 3.14159. Препроцессор заменит в оставшейся части программы все отдельно стоящие вхождения идентификатора PI на лексему, которую транслятор будет воспринимать как вещественный литерал 3.14159. Препроцессор выполняет грубую предварительную работу по замене замещаемых идентификаторов замещающими строками. В этот момент ещё ничего не известно об именах, поскольку транслятор фактически ещё не начинал своей работы. А потому следует следить за тем, чтобы заме__


щаемые идентификаторы входили в состав объявлений лишь как элементы инициализаторов. Рассмотрим несколько примеров. Директива препроцессора «define PI 3.14159 Превращает корректное объявление float

PI;

в синтаксически некорректную конструкцию float

3.14159; А следующее определение правильное,

float pi = Pi; После препроцессирования оно принимает такой вид: f l o a t p i = 3.14159; Сначала препроцессор замещает, затем транслятор транслирует. И потому здесь будет зафиксирована ошибка: «define

PI 3.14

0.00159

float pi = PI; После препроцессирования объявление принимает такой вид: f l o a t p i = 3.14

0.00159;

А здесь — всё корректно: «define PI 3.14 + 0.00159 f l o a t pi = PI; После препроцессирования получается правильное объявление с инициализацией: f l o a t p i = 3.14

+ 0.00159;

Строка замещения может оказаться пустой, «define _ _

ZZZ


В этом случае оператор-выражение ZZZ; и ещё более странные конструкции ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ; превращаются препроцессором в пустой оператор. Это лишь побочный эффект работы препроцессора. У макроопределений с пустой строкой замещения имеется собственная область применения. Строка замещения может располагаться на нескольких строках. При этом символ 'V уведомляет препроцессор о необходимости включения в состав строки замещения текста, располагаемого на следующей стоке. Признаком завершения многострочного определения является символ конца строки: #define TEXT "1234567890-=\ йцукентодЗхтД " В ходе препроцессорной обработки вхождения TEXT заменяются на строку замещения:

идентификатора

1234567890-= йцукенгшщэх"ь\ Макроопределения define могут быть составными: #include <iostream.h> #define WHISKEY "ВИСКИ с содовой." #define MARTINI "МАРТИНИ со льдом и " WHISKEY void main() {cout «

MARTINI;}

В результате выполнения последнего оператора выводится строка: МАРТИНИ со льдом и ВИСКИ с содовой. После каждого расширения препроцессор переходит к очередному макроопределению и заново повторяет процесс расширения. Препроцессорные замены не выполняются внутри строк, символьных констант и комментариев. При этом в замещающей части не должно быть вхождений замещаемой части макроопределения. Так что макроопределение #define WHISKEY "стаканчик ВИСКИ " WHISKEY


обречено на неудачу. В макроопределениях может встречаться несколько макроопределений с одной и той же замещаемой частью. При этом следует использовать в тексте программы директиву препроцессора # u n d e f ИмяЗамещаемойЧасти Эта инструкция прекращает действие препроцессора по замене соответствующего идентификатора. «define PI 3.14 + 0.00159 float pil = PI; #undef PI «define PI 3.14159 float pi2 = PI; Этот раздел мы завершаем ещё одним примером, связанным с инициализацией, определением размеров и количества элементов массива. Рассмотрим определение произвольного массива: l o n g LH[] = { 0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 } ; Нам сейчас абсолютно не важен тип и точное количество элементов этого массива. Важно, что выражение sizeof(LH) всегда равняется числу байтов, занимаемых множеством элементов массива в оперативной памяти. А выражение sizeof(LH[0]) оказывается равным количеству байтов, занимаемых одним элементом массива. Причём вместо нуля в качестве операнда операции индексации может стоять любое целое число. Можно предположить, что у операции sizeof будет достаточно "ума", чтобы не "путешествовать" по памяти в поисках конкретного элемента. В данном контексте речь идёт всего лишь об определении размера элемента массива. И не важно, существует ли такой элемент на самом деле. Очевидно, что выражение (sizeof(LH)/sizeof(LH[0])) оказывается равным количеству элементов данного массива. Таким образом, можно определить количество элементов массива, не считая его элементы в инициализаторе. Существует возможность определения размеров массива уже на этапе трансляции программы: long LH[] = {0,1,2,3,4,5,6,7,8,9}; /* Размер массива явно не указан.*/ «define MAX (sizeof(LH)/sizeof(LH[0])) __


/* Это макроопределение.*/ long LHdouble[MAX]; /* Идентификатор MAX при трансляции заменяется выражением (sizeof(LH)/sizeof(LH[0])), значение которого соответствует размеру массива. */ Такое определение массива не вызывает особых возражений у транслятора. После препроцессорной обработки выражение МАХ будет заменено выражением (sizeof(LH)/sizeof(LH[0])) и последний оператор определения массива будет выглядеть таким образом: long

LHdouble[(sizeof(LH)/sizeof(LH[0]))];

Значение константы MAX определяется на стадии трансляции, а это означает, что выражение (sizeof(LH)/sizeof(LH[0])) является константным выражением. Теперь можно не сомневаться в том, что идентификатор МАХ представляет собой константное выражение.

Директива препроцессора include Эта директива предписывает препроцессору поместить на её место содержимое другого файла. Существует два формата этой директивы. iinclude <ИмяФайла> При этом поиск производится только в пределах специфицированных заранее согласованных каталогов включаемых файлов. Эта информация препроцессору должна быть известна. iinclude

"ИмяФайла"

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

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


#if

КонстантноеВыражение

Эта директива обеспечивает проверку условия константного выражения. Директивы #elif

КонстантноеВыражение

«else

обеспечивают альтенативные варианты работы препроцессора. Директива #endif обозначает конец "зоны активности" препроцессора: #if КонстантноеВыражение /* Фрагмент кода компилируется, если константное выражение истинно, то есть не равно нулю. */ #elif КонстантноеВыражение /* Фрагмент кода компилируется, если второе константное выражение истинно, а первое ложно. */ «else /* Фрагмент кода компилируется, если все константные выражения оказываются равными нулю. */ #endif

Директивы #ifde£ Идентификатор #ifndef Идентификатор

определяют диапазон действия идентификатора, вводимого в программу директивой define. При этом препроцессор проверяет лишь факт вхождения данного идентификатора в директиву define. От результатов этой проверки также может зависеть дальнейшая работа препроцессора. И как раз в этом контексте и используется директива define с пустой строкой замещения. Она формирует управляющий сигнал, на который реагирует препроцессор при выполнении директив ifdef и ifndef. Аналогичное поведения препроцессора обеспечивается операцией defined, образующей выражения одного из двух видов: defined(Идентификатор) defined Идентификатор

Эти выражения позволяют проверить, был ли определён данный идентификатор или нет.

122


Результат выполнения операции defined оказывается равным 1, если соответствующий идентификатор ранее был определён директивой define и не был после этого "сброшен" директивой undef. При этом инструкция #if

! d e f i n e d Идентификатор

эквивалентна инструкции препроцессора #ifndef

Идентификатор

И вообще, использование операции defined() предпочтительнее, поскольку позволяет проверять несколько идентификаторов в сложных логических выражениях. #ifdef DOSTARGET #ifndef NDEBUG puts(msg) #endif #endif

Переписывается и упрощается: # i f defined(DOSTARGET) £& «defined(NDEBUG) p u t s (msg) #endif

Функциональные макроопределения Функциональные макроопределения по своему внешнему виду действительно напоминают выражение вызова функции. Замещаемая строка имеет вид идентификатора с заключённым в скобки списком параметров. #define max(m,n) (m > n ? m : n) ...max(X,Y)... Этот фрагмент программного кода заменяется препроцессором сначала на выражение замещения: ... (т > п ? т

: п)...

Затем в выражении замещения заменяются вхождения параметров. Сначала все вхождения параметра т : ... (X < п ? X : п)...

А затем — все вхождения параметра п: ... (X < Y ? X : Y)... Здесь аналогично: ...тах(Х,9)... ... (т > п ? ш : п)... ... (X < п ? X : п)... ... (X < 9 ? X : 9)... Так что перед нами всего лишь усложнённый вариант макроопределения. При подстановке строки замещения на место замещаемого выражения препроцессор использует лишь более изощрённые методы.

123


Следует помнить, что препроцессор действует по своим законам. Его логика поведения отлична от логики транслятора, и это может привести к неожиданным последствиям. Рассмотрим пример: #include <iostream.h> #include <stdlib.h> void main!) { int i = 10; int j = 14; int mx, mn; cout « "Before: i == " « mx = max(i++,j—); mx = min(i++,j—) ; cout «

" After: i == " «

i « " , j == " «

i «", j =

" «

j «

j «

endl;

endl;

Заголовочный файл stdlib.h содержит следующие функциональные макроопределения: «define «define

max(a,b) min(a,b)

( ( ( а ) > ( Ь ) ) ? (а) : (Ь)) (((а)<(Ь))?(а):(b))

Значения, которые будут выведены на дисплей, определяются следующей подстановкой: mx = ( ( ( i + + ) X j — ) ) ? ( ! + + ) : (j — ) ) ; mn = ( ( ( i + + X ( j ~ ) ) ? ( i + + ) : ( j — ) ) ; Очевидно, что переменные i и j в ходе выполнения программы изменяют свои значения по 3 раза.

Символ управления ##. Конкатенация в макроопределениях В следующем примере мы используем ещё один специалый символ для управления препроцессором - конкатенатор ##. Обычно конкатенатор используется в контексте функциональных макроопределений. Если в замещающей последовательности лексем между двумя лексемами встречается конкатенатор ## и, возможно, что одна или обе лексемы являются параметрами, то препроцессор вначале обеспечивает подстановку фактических значений в параметры, после чего сама операция конкатенации и окружающие её пробельные символы убираются: «include <iostream.h> «define XXX(YYY) "МАРТИНИ со льдом и" ## YYY void main () _


cout « XXX("ВИСКИ с содовой") « eridl; cout « XXX("на сегодня достаточно...") « endl; /* Для препроцессора тип параметра не имеет значения. Важно, как посмотрит на это транслятор... */ cout « XXX(255);

I Перед нами ещё одно мощное и изящное средство управления препроцессором.

Предопределённые макроопределения Фиксированное подмножество идентификаторов распознаётся компилятором как предопределённые макроопределения. При трансляции текста программы препроцессор распознаёт эти идентификаторы и подставляет вместо них определённые последовательности символов. Рассмотрим некоторые предопределённые макроопределения: • LINE — заменяется десятичной константой — номером текущей строки транслируемого файла. При этом номер первой строки исходного файла равен 1. • FILE — заменяется строкой символов — именем текущего файла. Под "текущим" подразумевается файл, который в данный момент находится в поле зрения препроцессора. В процессе выполнения директивы препроцессора #include "ИмяФайла" или #include <ИмяФайла> имя текущего файла меняется на имя включаемого файла. После окончания выполнения этой директивы восстанавливается старое значение имени. • DATE — заменяется строкой символов в формате "месяц число год". Таким образом, в код программы включается информация о дате обработки препроцессором исходного файла. • TIME — заменяется строкой символов в формате "часы:минуты:секунды". При этом, в код программы включается информация о времени обработки препроцессором исходного файла. • STDC — заменяется десятичной константой, которая принимает значение 1, если транслятор работает в соответствии с ANSIстандартом. В противном случае значение макроопределения не определено. Пример использования предопределённых макроопределений: ((include <iostream.h> char buildDate[J char buildTime[] =

DATE ; TIME ;

void main (void) i

1

cout « cout « )

"Дата: " « "Файл: " «

buildDate « " , время: " « FILE « •' , строка " <<

buildTime « endl; LINE « endl;

125


5. Функция Прототип Функция в C++ объявляется, определяется, вызывается. В разделе, посвященном структуре файла, в качестве примера мы уже рассматривали синтаксис определения функции. Определение функции состоит из заголовка и тела. Заголовок функции состоит из спецификаторов объявления, имени функции и списка параметров. Тело функции образуется блоком операторов. Синтаксис выражений вызова функции ранее был рассмотрен достаточно подробно. Это постфиксное выражение со списком (возможно пустым) выражений в круглых скобках. При разборе выражения вызова, транслятору C++ требуется информация об основных характеристиках вызываемой функции. К таковым, прежде всего, относятся типы параметров, а также тип возвращаемого значения функции. При этом тип возвращаемого значения оказывается актуален лишь в том случае, если выражение вызова являетсяется частью более сложного выражения. Если определение функции встречается транслятору до выражения вызова, никаких проблем не возникает. Вся необходимая к этому моменту информация о функции оказывается доступной из её определения: #include <iostream.h> void ZZ(int param) // Определение функции. { cout « "This is ZZ : " « param « endl; void main (void) { ZZ(10) ; // Вызов функции. Транслятор уже знает о функции всё.

При этом не принципиально фактическое расположение определения функции и выражения её вызова. Главное, чтобы в момент разбора выражения вызова транслятор располагал бы всей необходимой ему информацией об этой функции. Например, в таком случае: #include <iostream.h> #include "zz.cpp" /* Препроцессор к моменту трансляции "подключает" функции ZZ() из файла zz.cpp. */ void main

126

(void)

определение


ZZ(125); Файл zz.cpp: void ZZ(int parl) cout «

"This is ZZ : " «

parl «

endl;

Но как только в исходном файле возникает ситуация, при которой вызов функции появляются в тексте программы до определения этой функции, разбор выражения вызова завершается ошибкой: #include void main

<iostream.h> (void)

ZZ(10) ; /* Здесь транслятор сообщит об ошибке. */

void ZZ(int param) cout «

"This is ZZ : " «

param «

endl;

Каждая функция, перед тем, как она будет вызвана, по крайней мере, должна быть объявлена. Это обязательное условие успешной трансляции и вольный перевод соответствующего сообщения об ошибке (Call to undefined function 'ИмяФункции'), выдаваемого транслятором в случае вызова необъявленной функции. Напомним, что объявление и определение — разные вещи. Объект может быть много раз объявлен, но только один раз определён. Прототип функции при этом играет роль объявления функции. В объявлении функции сосредоточена вся необходимая транслятору информация о функции — о списке её параметров и типе возвращаемого значения. И это всё, что в момент трансляции вызова необходимо транслятору для осуществления контроля над типами. Несоответствие спецификации возвращаемого значения в объявлении прототипа и определении функции считается ошибкой. Несоответствия типов параметров в прототипе и определении функции выявляются на стадии окончательной сборки программы. #include

<iostream.h>

void ZZ(int ppp); /* Эта строка требуется для нормальной компиляции программы. Это и есть прототип функции. Имя параметра в объявлении может не совпадать с именем параметра в определении. */ void main

(void) _ _


ZZU25) ; } void ZZ(int parl) { cout « "This i s ZZ : " « parl « endl; ) Самое интересное, что и такое объявление не вызывает возражений транслятора. •include <iostream.h> void ZZ (int) ; /* Отсутствует имя параметра. Можно предположить, что имя параметра не является обязательным условием правильной компиляции. */ void main (void) < ZZ(125) ; } void ZZ(int parl) < cout « "This is ZZ : " « )

parl «

endl;

Правила грамматики подтверждают это предположение. Ранее соответствующее множество БНФ уже рассматривалось: ОбъявлениеПараметра ::= СгшсокСпецификаторовОб-ъявления Описатель : := СписокСпецификаторовОб'Ьявления Описатель Инициализатор ::= СписокСпецификаторовОб'ьявления [ АбстрактныйОписатель] [Инициалиэатор]

Из этих форм Бэкуса-Наура следует, что объявление параметра может состоять из одного спецификатора объявления (частный случай списка спецификаторов). Так что имени параметра в списке объявления параметров в прототипе функции отводится в букальном смысле роль украшения. Его основное назначение в прототипе - обеспечение легкочитаемости текста программы. Принципиальное значение имеет соответствие типов параметров в определении и объявлении функции. Попытка трансляции следующего примера программы оказывается неудачной. #include <iostream.h> void ZZ(float); void main (void) -_

// Другой тип параметра.


ZZU25) ; } void ZZ(int p a r l ) { cout « "This i s ZZ : " « p a r l « e n d l ; ) Если функция не возвращает значения, в объявлении и определении обязательно используется спецификатор объявления void. Функция также может не иметь параметров. В этом случае объявление параметров в определении и прототипе может быть либо пустым, либо может состоять из одного ключевого слова void. В контексте объявления параметров слово void и пустой список спецификаторов параметров эквивалентны.

Предварительная инициализация параметров функции Список параметров в определении и прототипе функции, кроме согласования типов параметров, имеет ещё одно назначение. Объявление параметра может содержать инициализатор, то есть выражение, которое должно обеспечить параметру присвоение начального значения. Инициализатор параметра не является константным выражением. Начальная инициализация параметров происходит не на стадии компиляции (как, например, выделение памяти под массивы), а непосредственно в ходе выполнения программы. Следующие строки демонстрируют пример объявления функции с инициализацией параметров. Для инициализации параметра ww используется функция XX. int BigVal; int XX(int) ; int ZZ(int tt, int ww = XX(BigVal));

Второй параметр можно проинициализировать и таким способом, вовсе не указывая его имени. Синтаксис объявления позволяет сделать и такое! i n t ZZ(int t t , i n t = XX(BigVal)); Единственное условие подобной инициализации — соответствие типа параметра и типа выражения, значение которого используется при начальной инициализации. Прототипы функции могут располагаться в различных областях видимости. Его можно даже разместить в теле определяемой функции. Каждое объявление функции может содержать собственные варианты объяв_ .


ления и инициализации параметров. Но во множестве объявлений одной и той же функции в пределах одной области видимости не допускается повторная инициализация параметров. Всему должен быть положен разумный предел. Кроме того, в C++ действует ещё одно ограничение, связанное с порядком инициализации параметров в пределах области видимости. Инициализация проводится непременно с самого последнего (самого правого) параметра в списке объявлений параметров. Инициализация параметров не допускает пропусков: инициализированные параметры не могут чередоваться с параметрами неинициализированными. int MyFl (int parl, int par2, int рагЗ, int par4 = 10); int MyFl (int parl, int par2 = 20, int рагЗ = 20, int par4); int MyFl (int parl = 100, int, int, int);

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

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


• на основе анализа прототипов вызываемой функции (в пределах областей их видимости) определяются начальные значения параметров, которые записываются в соответствующие разделы записи активации; • вычисляются значения выражений, которые образуют список параметров в выражении вызова. Поскольку вычисление значений производится в процессе выполнения программы, нет никаких ограничений на процесс определения значения выражений. Здесь можно использовать переменные и вызовы функций; • в строгом соответствии со списками параметров в прототипе и соответствующих значений в выражении вызова производится присвоение значений соответствующим элементам записи активации. При этом в списке выражений, определяющих значения параметров, не допускается никаких пропусков. Возможно, что части элементов области активации, соответствующим ранее проинициализированным по умолчанию параметрам будут присвоены новые значанния. Здесь не допускается неопределённых ситуаций. Либо элемент записи активации получает значение умолчания, либо ему заново присваивается значение при вызове; • в вызываемой функции параметрам (в том числе и неименованным) присваиваются значения из записи активации; • управление передаётся первому оператору функции. Означенные параметры используются как переменные с определёнными значениями. При этом обращение к безымянным параметрам затрудняется (но всё же возможно!), поскольку к ним нельзя обратиться по имени. Возвращение в вызывающую функцию сопровождается восстановлением состояния точки вызова. Для этого используются значения, сохранённые в стеке в момент вызова функции. Этот процесс сопровождается изменением состояния стекового сегмента ("разматыванием" стека). При этом запись активации функции становится недоступной, а выполнение очередного выражения вызова (очередное "наматывание" стека) полностью разрушает старую запись активации. В заключение несколько примеров. Первый пример иллюстрирует степень компетенции транслятора при предварительной инициализации параметра в прототипе и вызове функции со значением параметра по умолчанию. В прототипе функции используется абстрактный описатель. #include <iostream.h> int *pIntArr[25]; /* Объявлен (и определён) массив указателей на целое из 25 элементов. */ int MyFun(int *[5] = plntArr); /* Безымянный параметр (в данный момент его имя не актуально) прототипа объявлен с использованием абстрактного описателя. Этот параметр представляет собой массив из пяти указателей на объекты ти_ _


па int. Транслятор не контролирует соответствие размера массивов. Подобная инициализация не вызывает возражений. */ void main() int iVal = 111; pIntArr[l] = SiVal; cout « "MyFunO == " «

MyFunO «

endl;

int MyFun(int *pKeyArr[50]) /* Размеры массива-параметра в определении функции не соответствуют ранее заявленным размерам массива-параметра в прототипе. Однако трансляция проходит успешно, поскольку работа с индексами находится вне компетенции транслятора.*/ return *pKeyArr[1];

Второй пример является подтверждением того факта, что списки параметров в прототипе и определении функции находятся в пределах одной и той же области действия имени функции: в определении функции можно продолжить инициализацию параметров, которые не были ранее проинициализированы в её прототипах. Прототип, располагаемый в блоке или теле функции (локальной области действия имени), позволяет изменить предварительные значения параметров для располагаемых в этом же блоке выражений вызова данной функции. В C++ нет никаких жёстких правил, определяющих взаимное расположение определений и объявлений функций. Главное - это чтобы транслятор мог соотнести выражение вызова функции с соответствующим прототипом или определением. #include <iostream.h> void funl(int, int = 2, int = 1); void fun2(int, int, int); void fun2(int Keyl = 300,int Key2 = 200,int КеуЗ = 100) cout « cout « cout «

" Keyl == " « • Key2 == " « " КеуЗ == " «

Keyl « endl; Key2 « endl; КеуЗ « endl;

void funl(int Keyl = 30,int Key2,int КеуЗ) { fun2() ; fun2(10); fun2(10, 20); /* Эти выражения вызова требуют начальной инициализации всего списка параметров функции. Поэтому определение функции fun2 располагается выше определения функции funl */ fun2(10, 20, 30); _


void fun2(int = l,int = 2/int = 3 ) ; fun2() ; fun2(Keyl) ; fun2(Keyl, Key2) ; /* Эти выражения вызова для определения значений параметров используют "локальный" прототип функции fun2. Старые значения параметров этой функции в теле функции funl оказываются вне зоны видимости. */ fun2(Keyl, Кеу2, КеуЗ);

void main() { funl () ; funl(255); funl(255, 1024); /* Из-за этих выражений вызова определение функции funl располагается выше определения функции main. */ funl(11,22,33); }

В C++ при вызове функции параметры передаются по значению. Это означает, что вызываемая функция при своей работе имеет дело лишь с копиями значений выражений, представленных в списке параметров выражения вызова функции. При этом размеры соответствующей области активации определяются типом значения в списке параметров выражения вызова. При этом возможности обратной связи вызываемой функции ограничиваются её возвращаемым значением. Ситуация изменяется, если в качестве параметров при вызове функций используются указатели. При этом вызывающая функция передаёт вызываемой функции в качестве параметра адрес объекта. Вызываемая функция получает копию значения, которая продолжает оставаться адресом конкретного объекта. Передача адресов в качестве значений параметров имеет определённые преимущества. Во-первых, адреса имеют фиксированные размеры. Во-вторых, используя операцию разыменования, вызываемая функция может менять значения объектов, определённых в других функциях. Последний пример раздела иллюстрирует возможности указателя в качестве параметра. #include <iostream.h> int fff(int, int * ) ; void main()

I int vail = 1, val2 = 1, rep = 0; cout « cout «

"Значения vail и val2 до вызова функции : "; vail « ", " « val2 « endl; __—


rep = fff(vall, &val2); cout «

"Значение, которое возвратила функция : " «

rep « endl;

cout « cout «

"Значения vail и val2 после вызова функции : "; vail « «, " « val2 « endl;

int fff(int parl, int * par2) 1 cout «

"Значение первого параметра : " «

parl « endl;

cout « cout «

"Значение второго параметра : " « par2; "... Это адрес." « endl;

cout « cout «

"Значение объекта, представленного параметром par2 : "; *par2 « endl;

/* По адресу изменяем значение объекта, определённого в вызывающей функции.*/ *par2 += parl; return (parl + *par2);

Массивы и параметры В C++ возможно лишь поэлементное копирование массивов. Этим объясняется то обстоятельство, что в списке объявлений параметров не объявляются параметры-массивы. В Borland C++ 4.5 транслятор спокойно реагирует на объявление одномерного массива в заголовке функции, проверяет корректность его объявления (размеры массива должны быть представлены константными выражениями), однако сразу же игнорирует эту информацию. Объявление одномерного массива-параметра преобразуется к объявлению указателя. Подтверждением этому служит тот факт, что "массив"-параметр невозможно проинициализировать списком значений, что совершенно нормально для обычных массивов: void ff(int keyArr[

] = // void ff(int keyArr[10] = //

{0,1,2,3,4,5,6,7,8,9}); Ошибка объявления, {0,1,2,3,4,5,6,7,8,9}); Ошибка объявления.

Оба варианта прототипа функции будут отвергнуты. При этом транслятор утверждает, что указателю (и это несмотря на явное указание размеров массива!) можно присваивать значение адреса, либо NULL. 134


int keyArr[100]; // Глобальный массив. int xArr[5]; // Ещё один глобальный массив. int XXX; // Простая переменная. void ff(int keyArrt 1] = keyArr, //Объявление одноименного параметра. int pArrl [10] = xArr, int pArr2 [ ] = &XXX, // Адрес глобальной переменной. int рАггЗ [ ] = 6xArr[10], //Адрес несуществующего элемента. int pArr4 [50] « NULL); /* Допустимые способы инициализации массивов в прототипе функции свидетельствуют о том, что здесь мы имеем дело с указателями. */

Следующий пример подтверждает тот факт, что объявление одномерного массива в списке параметров оказывается на самом деле объявлением указателя. •include <iostream.h> void fun(int *, int[], int qwe[10] = NULL); /* Все три объявления параметров на самом деле являются объявлениями указателей. */ void < int int /* В

main () Arr[10] = {0,1,2,3,4,5,6,7,8,9}; *pArr = Arr; функции main определены массив и указатель.*/

cout « Arr « " " « SArr « " " « SArr[0] « endl; cout « pArr « " " « SpArr « • " « SpArr[0] « endl; /* Разница между массивом и указателем очевидна: значение выражения, представленного именем массива, собственный адрес массива и адрес первого элемента массива совпадают.*/ fun(Arr, Arr, Arr); void fun(int* pArrl, int pArr2[], int pArr3[100]) cout « cout « cout «

sizeoftpArrl) « sizeof(pArr2) « sizeof(рАггЗ) «

cout « cout « cout «

pArrl « pArr2 « рАггЗ «

я

" *

endl; endl; endl;

" « SpArrl « " " « SpArr2 « " " « SpArr3 « "

" « SpArrl [0] « endl; " « 6pArr2 [ 0 ] « endl; " « SpArr3[0] « endl;

/* Все параметры проявляют свойства указателей. */

135


Так что размеры массива в объявлении параметра, подобно имени параметра в прототипе, являются лишь украшением, которое предназначается для напоминания программисту о назначении параметра. При вызове функции передаются либо отдельные элементы массива и тогда мы имеем тривиальный список параметров, либо адреса, которые воспринимаются как адреса начальных элементов массивов. В последнем случае неизвестными оказываются размеры массива, однако, эта проблема решается благодаря введению дополнительного целочисленного параметра, задающего размеры массива, представленного указателем. Следующий пример демонстрирует возможный вариант решения проблемы передачи в вызываемую функцию переменного количества однотипных значений. Подлежащие обработке данные в вызывающей функции располагаются в непрерывной области памяти (в нашем примере это целочисленный массив Агг). При этом обрабатывающая функция имеет два параметра, один из которых является указателем на объект обрабатываемого типа (в определении функции закомментированы альтернативные варианты объявления этого параметра), второй - целочисленного типа. В выражении вызова значением первого параметра оказывается адрес первого элемента массива, значением второго параметра - количество обрабатываемых элементов массива. Таким образом, функция с постоянным количеством параметров позволяет обрабатывать заранее неизвестное количество значений. #include <iostream.h> void fun(int * = NULL, int

0);

void main()

i n t Arr[10] = {0,1,2,3,4,5,6,7,8,9}; fun(Arr, 10) ; fun(Arr, sizeof(Arr)/sizeof(Arr[0])); } void fun(int* pArr /* int pArr[] */ /* int pArr[150] */, int key) { for ( key—; key >= 0; key—) cout « pArr[key] « endl; ) Фактическое тождество одномерного массива и указателя при объявлении параметров определяет специфику объявления многомерных массивов-параметров. В C++ многомерный массив - понятие условное. Как известно, массив размерности п является одномерным массивом множества объектов типа массив размерности п-1. Размерность массива является основной характеристикой данного типа. Отсюда — особенности объявления многомерных массивов как параметров функций. Следующий пример содержит множество прототипов неопределённых функций с именем fun. Кроме того, программа содержит единственное определение функции с тем же самым именем fun и параметром, пред-

"136


ставляющим собой целочисленный трёхмерный массив со значениями размерностей 75*3*5. Транслятор спокойно реагирует на различные варианты прототипов функции fun в начале программы. Если последовательно комментировать варианты объявлений функции, ошибка будет зафиксирована лишь тогда, когда будут закомментированы все объявления, у которых характеристика второй и третьей размерности совпадает с аналогичными характеристиками многомерного параметра-массива в определении функции. •include <iostream.h> «define DIM1 3 tdefine DIM2 5 // void fun(int rrr[] [][]); /* Такой прототип неверен! Квадратные скобки в объявлении параметра, начиная со второй, обязательно должны содержать константные выражения, значения которых должны соответствовать значениям в квадратных скобках (начиная со второй!) в объявлении параметра в определении функции. Эти значения в контексте объявления параметров являются элементами спецификации ТИПА параметра, а не характеристиками его РАЗМЕРОВ. Типы составляющих одномерные массивы элементов в прототипе и заголовке определения функции должны совпадать. */ //void fun(int rrr[5][DIMl] [DIM2]) ; void fun(int rrr[] [3] [5]) ; void fun(int rrr[15][DIMl] [5]) ; void fun(int *rrr[3][DIM2]); /* Во всех этих случаях параметр rrr является указателем на двумерный массив из 3*5 элементов типа int. "Массив из трёх по пять элементов типа int" - такова спецификация типа объекта. */ /* Следующие два прототипа, несмотря на одно и то же имя функции, являются прототипами разных функций. Одноимённые функции с различными списками параметров называются перегруженными функциями. */ void fun(int *rrr[25][250]); void fun(int rrr[50][100][DIMl]); void main!) { int Arr[2] [DIMl] [DIM2] = { {1 2 , 3 , 4 ,5 }, {10 2 0 , 3 0 , 40 , 5 0 } , {11 1 2 , 1 3 , 14 , 1 5 ) , }, <1, } t (2, } {3, )

_


fun(Arr); /* Вызов fun. Значение параметра - адрес начала массива. Значение первой размерности массива неактуально.*/ void funfint pArr[75][DIM1][DIM2])

cout « sizeof(pArr) « endl; cout « pArr « " " « tpArr « " " « &pArr[0] [0] « /* Параметр проявляет свойства указателей. */

endl;

cout « sizeof(*pArr) « endl; cout « *pArr « • " « 6*pArr « " " « &*pArr[0][0] « endl; /* Если применить к указателю операцию разыменования, можно убедиться в том, что параметр указывает на массив. При этом о топологии многомерного массива можно судить исключительно по косвенной информации (в данном случае - по значениям константных выражений DXM1 и DIM2) или по значениям дополнительных параметров. */

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

Функции с изменяемым списком параметров Для решения задачи передачи неопределённого количества параметров C++ располагает также средствами объявления переменных списков параметров. Вспомним несколько форм Бэкуса-Наура, определяющих синтаксис списка параметров в определении и прототипе функции. СхемаОб'ъявленияПараметров : : = f СписокОб'ьявленийПараметров ] (...] : := СписокОб'ъявленийПараметров, . . . СписокОб'ъявленийПараметров : := Об'ъявлениеПараметра : : = [СписокОб'ъявленийПараметров, ] Об'ъявлениеПараметра

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


В этом случае количество и тип параметров становятся известны из списка выражений, определяющих значения параметров в выражении вызова функции. Рассмотрим прототип и определение функции с переменным количеством параметров. i n t PP(...) ; i n t PP(...) { r e t u r n 100; } Трансляция этого фрагмента кода не вызывает у транслятора никаких возражений. Многоточием в списке параметров он предупреждён о возможных неожиданностях. Следующий фрагмент кода демонстрирует варианты выражений вызова функции РР(). int retVal; retVal = РР () ; retVal = РР(1,2 + retVal,3,4,5,25*2) ; РР('z',25,17);

В ходе выполнения выражений вызова функций с переменным количеством параметров изменяется алгоритм формирования записи активации. Теперь он выглядит примерно так: • в стековом сегменте выделяется запись активации. Теперь размер записи активации зависит от количества и типа параметров в выражении вызова, (а не прототипа). Так что сначала нужно определить и запомнить общее количество и тип выражений, которые образуют список параметров в выражении вызова функции. Как и раньше, вычисление значений производится в процессе выполнения программы. Как и раньше, значение параметра может быть представлено любыми выражениями; • на основе анализа прототипов вызываемой функции, расположенных в области видимости вызывающей функции, определяются начальные значения параметров (если они имеются), которые и записываются в соответствующие области записи активации. Как мы увидим дальше, в функциях с переменным количеством параметров слева от многоточия всё же может находиться хотя бы один явным образом определённый параметр. В противном случае просто не будет возможности воспользоваться значениями параметров; • вычисляются значения выражений, которые образуют список параметров в выражении вызова. Поскольку вычисление значений производится в ходе выполнения программы, здесь также нет никаких ограничений на процесс определения значения выражений. Можно использовать любые значения, а также вызовы ранее объявленных функций; _


• элементам записи активации присваиваются вычисленные значения. При этом возможно, часть параметров, которым были присвоены значения умолчания (это всегда ближайшие к многоточию параметры), получит новые значения. В этом процессе не допускается неопределённых ситуаций. Либо элемент записи активации получает значение умолчания, либо ему присваивается значение при вызове. Нарушение порядка означивания, как и раньше, выявляется ещё на стадии трансляции программы; • в вызываемой функции всем параметрам, которые были указаны в списке параметров, присваиваются значения из записи активации. Для остальных (непостоянных и, естественно, безымянных) параметров выделяется дополнительная память. Эти параметры также получают свои значения из записи активации; • управление передаётся первому оператору функции. Означенные параметры используются как переменные с определёнными значениями. Доступ к безымянным параметрам, в силу того, что к ним невозможно обращение по имени, обеспечивается специальными алгоритмами. Итак, параметрам вызываемой функции присвоены соответствующие значения, представленные в выражении вызова. Возникает вопрос, как воспользоваться этими значениями в теле вызываемой функции. Если у параметра существует собственное имя, то всё очевидно. Если же параметр был определён как параметр без имени, то существует единственный способ доступа к таким параметрам — доступ с помощью указателей. Если предположить, что все означенные параметры, с именами и безмянные, занимают одну непрерывную область памяти. Поэтому для доступа к элементам этого списка достаточно знать имя и тип хотя бы одного параметра. Для этого в функции определяется указатель, которому с помощью операции взятия адреса присваивается значение, которое соответствует адресу именованного параметра. Переход от параметра к параметру при этом обеспечивается с помощью операций адресной арифметики над значением этого указателя. С точки зрения реализации всё очень просто. Если бы не одно обстоятельство, которое заметно ограничивает свободу применения подобных функций. Дело в том, что всякий раз при создании функций с неопределённым количеством параметров, мы вынуждены разрабатывать алгоритм доступа к списку этих самых параметров. А для этого необходимо, по крайней мере, представлять закономерность расположения параметров в списке. Так что список необъявленных параметров не может состоять из подобранных случайным образом элементов, поскольку не существует универсальных средств распознавания элементов этого списка. На практике дело обычно ограничивается несколькими тривиальными вариантами.

140


При этом либо известен тип и количество передаваемых параметров, и процедура доступа к параметрам сводится к примитивному алгоритму, который воспроизводится в следующем примере: #include <iostream.h> long PP(int n,

...);

void main (void) { long RR; RR = PP(5, 1, 2, 3, 4, 5 ) ; /* Вызвали функцию с 6 параметрами. Единственный обязательный параметр определяет количество передаваемых параметров. */ cout «

long < int // int

RR «

endl;

PP(int n ...) *pPointer = &n; Настроились на область памяти с параметрами... Sum = 0;

for ( ; n; n — ) Sum += *(++pPointer); return Sum; 1 Либо известен тип элементов списка и признак завершения списка передаваемых параметров. Процедура доступа к параметрам также проста, как и в первом случае: #include <iostream.h> long PP(int parl

...);

void main (void) { long RR; RR = PP( 1, 2, 0, 4, 0 ) ; /* Вызвали функцию с 5 параметрами. Единственный обязательный параметр - первый параметр в списке параметров. */ cout «

RRR «

endl;

long PP(int parl ...) { int *pPointer = Sparl; _


/* Настроились на область памяти с параметрами. Признак конца списка - параметр с нулевым значением. */ int Sum = 0; for ( ; *pPointer != 0; pPointer++) Sum += *pPointer; /* Что-то здесь не так,.. Мы так и не обработали до конца весь список. */ return Sum;

Размещение параметров Помимо ограничений, связанных с алгоритмами доступа к параметрам, реализация функций с переменным количеством параметров осложняется также рядом дополнительных обстоятельств, которые требуют особого внимания. При вызове функции значения её параметров помещаются в стек. При этом порядок размещения параметров в стеке зависит от реализации компилятора и от опций, позволяющих управлять размещением значений параметров. Так, стандартное размещение параметров предполагает, что значение последнего параметра размещается самым первым, а значение первого - самым последним. При вызове функции этот параметр, извлекается из стека в первую очередь. Вместе с тем, в Borland C++ существует возможность изменения порядка размещения значений параметров. Опция -р в командной строке компилятора, модификатор pascal в объявлении конкретной функции или соответствующий выбор пункта меню диалога Options Project приводит к изменению порядка расположения значений параметров. Значение первого параметра попадает в стек первым и извлекается последним. В этом случае функции с переменным количеством параметров могли бы использоваться лишь при условии, что всякий раз последним параметром будет параметр, содержащий информацию по крайней мере о количестве параметров в списке. Но это слишком жёсткое требование. И потому невыполнимое. Многоточие в прототипе или определении функции в этом случае воспринимается как ошибка. При определении функций с переменным количеством параметров, стандартом языка рекомендуется использовать специальный набор макроопределений, которые становятся доступными при включении в программу заголовочного файла stdarg.h. При этом облегчается переносимость программ с одной платформы на другую (платформа определяется типом компьютера и операционной системы.) Эти макроопределения обеспечивают простой и стандартный (не зависящий от реализации) способ доступа к спискам параметров переменной длины и имеют следующий формат: "142


void va_start (va_list ap, lastarg); Первый "параметр" этого макроопределения имеют специальный тип vajist, который используется для представления списков параметров неизвестной длины и состава. Макроопределение va_start вызывается непосредственно перед началом работы с неименованными параметрами. В результате вызова этого макроопределения "параметр" ар инициализируется указателем на последний именованный параметр в списке с переменным числом параметров — "параметр" lastarg. Не случайно здесь слово параметр взято в кавычки. Здесь речь идёт не о функциях с настоящими параметрами, а о макроопределениях. На стадии трансляции эти макроопределения заменяются программным кодом, который в различных версиях транслятора будет различным. Выражения, заключённые в скобки макроопределения, можно лишь условно называть параметрами. Назначение второго параметра макроопределения — указатель на последний параметр из списка параметров, объявленных в прототипе функции. Следующее макроопределение type va_arg

(va_list ар,

type);

После вызова макроопределения va_start, каждый вызов va_arg возвращает значение заказанного типа type (всё скоро прояснится на конкретном примере), которое берётся из списка параметров и модифицирует переменную ар так, чтобы она указывала на адрес следующего параметра в списке параметров. Здесь ничего не делается само по себе. Надо заранее указать тип желаемого параметра. В некоторых реализациях с макроопределением va_arg запрещено использовать типы char, unsigned char, float. Даже в макроопределениях, предназначенных для стандартизации языка, многое зависит от реализации. Работа со списком параметров завершается вызовом макроопределения v o i d va_end

(va_list ap);

Это макроопределение обеспечивает корректный возврат из функции и вызывается перед выходом из функции. Рассмотрим пример, в котором определяется функция с переменным числом параметров, предназначенная для вывода на печать целого числа или вещественного числа в формате, сходном с форматом функции printf, объявление которой содержится в заголовочном файле stdio.h. Это функция OK_print: #include <iostream.h> •include <stdarg.h>

143


void OK_print(char* formatString ...) « va_list ар; // Указатель на список параметров. va_start (ар, formatString); // Настроились на список параметров. /* Теперь в цикле будем символ за символом читать строчку - первый параметр функции. В соответствии с закодированной в этой строке информацией мы будем извлекать из списка параметров переменной длины очередные значения, которые и будем выводить в нужном формате. */ for (char* p = formatString; *p; p++) {/ / ============:—======—===== == if (*р == *%') // возможно, что это начало формата... { switch (*++p) i//

case 'd' : int ival = va_arg(ap,int); /* Ключевое слово int в качестве параметра! Такое возможно только в случае макроопределения. Л вот значение переменной ival получается именно типа int. Получаем то, что сами заказываем. */ cout « ival; break; case »*' : double dval = va_arg(ap,double); cout « dyal; break; \ii

~~

—~~~~

} else cout « *p; }//============================ va_end(ap); > Таким вот способом, извлекая необходимую информацию из форматной строки, мы заказываем очередные значения предопределённого типа из списка параметров. Очевидно, что информация в строке должна соответствовать передаваемым параметрам.

inline функции При объявлении и определении функция может быть использован спецификатор inline. Функция, содержащая этот спецификатор объявления, называется встраиваемой или inline-функцией. При трансляции оператор вызова inline-функции может быть заменён модифицированной копией кода этой функции. Сколько вызовов, столько экземпляров модифицированного кода. 144


В ходе выполнения исполняемого модуля, встраиваемые функции позволяют экономить время, необходимое для передачи управления, включая время, необходимое для сохранения и восстановления регистров, копирования параметров и т.п. В результате может быть получен выигрыш в эффективности. Однако этот выигрыш достигается за счёт того, что код встраиваемой функции появляется в каждой точке её вызова. А это требует дополнительных затрат памяти. При этом само встраивание не гарантировано. Каждая функция может быть объявлена как встроенная функция, но не каждая может быть встроена. Дело в том, что inline спецификатор — всего лишь намёк транслятору на то, что подстановка кода функции предпочтительнее реализации вызова функции. Окончательное решение о встраивании принимается транслятором. Здесь многое зависит от реализации языка и архитектуры компьютера. Так, согласно "Справочному руководству по C++", критериями "встраиваемости" функций могут оказаться: • физическая возможность выполнения встраивания. Это ограничение актуально для функций, содержащих собственный вызов, • ограничения по памяти. Функция может иметь размер кода, соизмеримый с размером программы, • наличие во встраиваемой функции операторов перехода. Подобная функция, скорее всего, не будет встроена, по той причине, что в область действия имён функции, содержащей вызов встраиваемой функции, попадают новые имена, что может вызвать непредвиденные последствия, • наличие оператора выбора или оператора цикла. Тело цикла образует замкнутый блок операторов, а переменные в выражениях управления циклом и тем более в условных операторах и операторах выбора попадают в пространство имён вызывающей функции, • повторное вхождение встраиваемой функции входит в одно и то же выражение. В этом случае могут возникнуть проблемы по восстановлению значений переменных после первого выполнения тела встраиваемой функции. Кроме того, транслятор заменяет вызов функции непосредственной подстановкой кода лишь тогда, когда определение функции предшествует её вызовам. Вопрос о критерии встраиваемости связан также с эффективностью выполнения преобразованного кода для машин с различной архитектурой, типом процессора и специальным оборудованием. Главный критерий — эффективность выполнения при одинаковом результате.

145


Функция main: старт программы Мы уже не раз встречались с этой функцией. Её основное назначение - обеспечить "вход" в программу. Благодаря её присутствию, транслятор всегда знает, откуда следует начинать выполнение программы. Стартовая функция не имеет прототипа. Любая программа может содержать несколько объявлений и определений различных одноименных функций (такие функции называются совместно используемыми или перегруженными - о них позже). Функция main всегда уникальна. Интересно, что в Borland C++ 4.5 не допускаются выражения вызова функции main. В этой версии языка возврат к месту старта программы запрещён. функция main не может быть объявлена со спецификаторами static или inline, её имя также не может быть операндом операции определения адреса &. функция main может содержать параметры. Типы и количество параметров ничем специально не регламентируются, однако для обеспечения передачи значений в программу при её вызове из командной строки, рекомендуется придерживаться следующего стандартного списка параметров: ... ( i n t a r g c ,

char* argv[])

Интересно, что второй параметр представляется в виде массива указателей типа char. Размерность этого массива указывает на то, что параметр argv на самом деле является указателем на указатель типа char: ...(int argc, char* * argv) Однако исходная форма объявления этого параметра наглядней и проще: i n t main(int argc,

char* argv[])

{/*

*/}

При этом значение первого параметра интерпретируется как количество параметров, переданных программе из окружения, в котором выполняется программа. Если значение первого параметра отлично от нуля, эти параметры должны задаваться как заканчивающиеся нулём строки литер, располагаемые по указателям от argv[O] до argv[argc-1]. Строка, располагаемая по адресу argv[0], должна содержать имя (имя файла, содержащего исполняемый модуль), используемое для вызова программы, либо пустую строку. Все эти ограничения и соглашения относительно функции main обеспечивают реализацию стандартного интерфейса между программой на языке C++ и её окружением. _ _


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

(int);

а вовсе не выражение, содержащее операцию return. Так что это ещё вопрос, кому предназначается возвращаемое значение, когда в функции main встречается выражение return

1;

typedef-объявление На стадии компиляции производится полная идентификация типов всех входящих в программу выражений. Даже отсутствие имени типа в объявлении как, например, unsigned long МММ; // Вместо имени типа - комбинация модификаторов unsigned long.

восстанавливается транслятором в соответствии с принятыми в C++ правилами умолчания. Помимо явного объявления типа в C++ предусмотрены дополнительные средства описания имён типов. Таким средством является typedef-объявление. С его помощью в программу можно ввести новые имена, которые затем используются для обозначения основных и производных типов. typedef-объявление - это инструмент объявления. Средство ввода новых имён в программу, средство замены громоздких последовательностей имён в объявлениях (но не определениях!) новыми именами. Синтаксис typedef-объявления как подмножества объявления представляется внушительным списком форм Бэкуса-Наура. Но при известной степени концентрации это нагромождение БНФ всё же можно разобрать: Объявление : : = « /'СписокСпецификаторовОбъявления./ усписокОписателей] ; СписокСпецификаторовОб'ъявления : : = СпецификаторОбтьявления [СписокСпецификаторовОб'ъявления] СпецификаторОбъявления : : = t y p e d e f _ _


СписокОписателей

::= [СписокОписателей,]

ОписательИнициализатор Описатель

: : = Описатель

ОписательИнициализатор [Инициализатор]

: : = ДИмя :;ж

*****

с1Имя : : = Имя : : = ОписанноеИмяТипа :;= ***** ОписанноеИмяТипа : : = Идентификатор СписокСпецификаторовТипа : : = СпецификаторТипа /'СписокСпецификаторовТипа ] СпецификаторТипа

: : = ИмяПростогоТипа : : = СпецификаторКласса ;>ш

*****

Таким образом, typedef-объявление является объявлением, которое начинается спецификатором typedef и состоит из последовательностей разнообразных спецификаторов объявления и описателей. Список описателей (элементы списка разделяются запятыми) может содержать языковые конструкции разнообразной конфигурации. В него могут входить описатели (в конце концов, это всего лишь разнообразные имена) с символами р^Операций (* и &), описатели, заключённые в круглые скобки, описатели в сопровождении заключённых в скобки списков объявлений параметров, описателей const и volatile, а также заключённых в квадратные скобки константных выражений (последние, надо полагать, предназначены для спецификации массивов). В качестве примера рассмотрим, следующее typedef-объявление: typedef

i n t Step,

*plnteger;

Это объявление начинается спецификатором typedef, содержит спецификатор объявления int и список описателей, в который входит два элемента: имя Step и имя plnteger, перед которым стоит символ рМЭперации *. Объявление эквивалентно паре typedef-объявлений следующего вида: typedef typedef

148

i n t Step; i n t *plnteger;


В соответствии с typedef-объявлениями, транслятор производит серию подстановок, суть которых становится понятной из анализа примера, в котором пара операторов объявления Step StepVal; e x t e r n p l n t e g e r pVal; заменяется следующими объявлениями: i n t StepVal; e x t e r n i n t * pVal; На основе этого примера можно попытаться воспроизвести алгоритм подстановки: • после возможного этапа декомпозиции списка описателей typedefобъявления, в результате которого может появиться новая серия typedefобъявлений, транслятор переходит к анализу операторов объявлений; • в очередном операторе объявления выделяется идентификатор, стоящий на месте спецификатора объявления; Ш среди typedef-объявлений производится поиск соответствующего объявления, содержащего вхождение этого идентификатора в список описателей. Таким образом, транслятор находит соответствующий контекст для подстановки. Мы будем называть этот контекст контекстом замены. Контекст замены оказывается в поле зрения транслятора вместе с оператором объявления, в котором транслятор различает спецификатор объявления и описатель; • оператор объявления заменяется контекстом замены, в котором совпадающий со спецификатором объявления идентификатор заменяется соответствующим описателем. Если в программе присутствует typedef-объявление typedef c h a r * (*PPFF)

(int,int,int*,float);

то компактное объявление PPFF R e t u r n e r F ( i n t ,

int);

преобразуется при трансляции в сложное, но как мы далее увидим, абсолютно корректное объявление функции: char*

(*ReturnerF(int, i n t ) ) ( i n t , i n t , i n t * , f l o a t ) ;

При этом по идентификатору PPFF в прототипе функции находится контекст замены char* (*PPFF) (int,int,int*,float), в котором замещаемый описатель PPFF заменяется замещающим описателем ReturnerF(int, int). __


Цель достигнута. Простое становится сложным. И как хорошо, что всё это происходит без нашего участия! Перед нами очередное средство для "облегчения" труда программиста. Заметим, что подстановка возможна и в том случае, когда замещаемый описатель заменяется пустым замещающим описателем. То же самое typedef-объявление позволяет построить следующее объявление функции: v o i d MyFun ( i n t , i n t , i n t * , f l o a t ,

PPFF);

Рассмотрим ещё один пример. typedef long double NewType; /* Используем спецификатор для в в о д а в программу н о в о г о имени т и п а . */

NewType MyFloatVal; •

Новое имя для обозначения типа введено... Новое имя ранее уже поименованного типа называют ОПИСАННЫМ ИМЕНЕМ ТИПА. Именно таким образом и назывался (так выглядел) соответствующий нетерминальный символ во множестве БНФ, связанных с typedef-объявлением. Описанное имя типа может заменять прежнее имя типа везде, где это возможно, поскольку объявления с описанным именем при трансляции заменяется первоначальным объявлением: long double MyFloatVal; В ряде случаев описанное имя типа может оказаться единственным именем для обозначения безымянного типа (об этом позже). В области действия объявления имени типа (typedef-объявления), идентификатор NewType (он является спецификатором типа) становится синонимом другого спецификатора типа — конструкции long double. Иногда подобным образом вводимый синоним называют замещающим идентификатором. Использование спецификатора typedef подчиняется следующим правилам (ничто не даётся даром): 1. Спецификатор typedef может переопределять имя как имя типа, даже если это имя само уже было ранее введено typedef спецификатором: typedef int I; typedef I I;

150


2. Спецификатор typedef не может переопределять имя типа, объявленное в одной и той же области действия, и замещающее имя другого типа. typedef typedef

int I; f l o a t I ; / / Ошибка: п о в т о р н о е описание...

3. На имена, введённые в программу с помощью спецификатора typedef, распространяются правила области действия, за исключением разрешения на многократное использование имени (правило 1.).

Тип функции Основными характеристиками функции является тип возвращаемого значения и список типов параметров. Подобно тому, как имена переменных никаким образом не влияют на их тип, имена функций не является частью их типа. Тип функции определяется типом возвращаемого значения и списком типов её параметров. Например, пара функций char MyFl (int, int, int*, float); char MyNew (int MyPl, int MyP2, int* МуРЗ, float МуРЗ); имеют один и тот же тип: char

(int, int, int*,

float)

Подобную конструкцию мы назовём описанием типа функции. А вот как выглядит описание типа функции, которая возвращает указатель на объект типа char: char * (int, i n t , i n t * ,

float)

Описанию этого типа соответствует, например, функция c h a r *MyFp ( i n t MyPl, i n t . MyP2, i n t * МуРЗ, f l o a t

МуРЗ);

Комбинируя знак ptr-операции * с именем функции мы получаем новую языковую конструкцию: char (*MyPtl) (int MyPl, int MyP2, int* МуРЗ, float МуРЗ); Это уже не объявление функции. Это определение указателя на функцию! Это объект со следующими характеристиками: • его имя MyPti, • это указатель на функцию, • эта функция должна возвращать значения типа char, _


список её формальных параметров имеет вид (int.int.int*, float).

Так что это должны быть функции со строго определёнными характеристиками. В нашем случае — это функции типа char

( i n t , int., i n t * ,

float)

Описание типа указателя на функцию, возвращающую указатель на объект типа char с параметрами (int, int, int*, float) char * (int, i n t , i n t * ,

float)

отличается от описания типа этой функции дополнительным элементом

О:

char * (*) (int, i n t , i n t * , f l o a t ) . Пример определения подобного указателя: char*

• • • •

(*MyPt2)

( i n t M y P l , i n t MyP2, i n t * МуРЗ, f l o a t

МуРЗ);

И опять новый объект: его имя MyPt2, это указатель на функцию, эта функция должна возвращать указатель на объекты типа char, список её формальных параметров имеет вид (int.int.int*, float).

Также можно определить функцию, которая будет возвращать указатель на объект типа void (то есть просто указатель). Это совсем просто: void *

(int)

Описанию этого типа соответствует, например, функция v o i d *malloc ( i n t

size);

Эта функция пытается выделить блок памяти размера size и в случае, если это удалось сделать, возвращает указатель на выделенную область памяти. В противном случае возвращается специальное значение NULL. Как распорядиться выделенной памятью — личное дело программиста. Единственное ограничение заключается в том, что при этом необходимо использовать явное преобразование типа: «include <stdlib.h> char *p = NULL;

152


void NewMemory () { p = malloc(sizeof(char)*1024);// Этот оператор не пройдёт! р = (char*) malloc(sizeof(char)*1024); // Требуется явное преобразование типа. } Имя массива, если к нему не применяется операция индексации, оказывается указателем на первый элемент массива. Аналогично, имя функции, если к нему не применяется операция вызова, является указателем на функцию. В нашем случае ранее объявленная функция под именем MyFp приводится к безымянному указателю типа char * (*)

(int,

int,

int*,

float)

К имени функции может быть применена операция взятия адреса. Её применение также порождает указатель на эту функцию. Таким образом, MyFp и &MyFp имеют один и тот же тип. А вот как инициируется указатель на функцию: char* (*MyPt2)

(int,

int,

int*, float)

= MyFp;

Очевидно, что функция MyFp() должна быть к этому моменту не только объявлена, но и определена. Новому указателю на функцию char*

(*MyPt3)

(int,

int,

int*,

float);

можно также присвоить новое значение. Для этого достаточно использовать ранее определённый и проинициализированный указатель: MyPt3 = MyPt2; Или адрес ранее определённой функции: MyPt3 = MyFp; При этом инициализация и присваивание оказываются корректными лишь при условии, что имеет место точное сопоставление списков формальных параметров и списков формальных значений в объявлениях указателей и функций. Для вызова функции с помощью указателя использование операции разыменования не обязательно. Полная форма вызова c h a r * MyPointChar =

(*МуРТЗ)(7,7,NULL,7.7);

имеет краткую эквивалентную форму _


char* MyPointChar • МуРТЗ(7,7,NULL,7.7); Значением выражения МуРТЗ является адрес функции. А вот каким образом описывается массив указателей на функцию: char* (*MyPtArray[3]) (int, int, int*, float); Здесь описан массив указателей из 3 элементов. Инициализация массива указателей возможна лишь после объявления соответствующих (например, трёх однотипных) функций: extern char* MyFFl (int, int, int*, float); extern char* MyFF2 (int, int, int*, float); extern char* MyFF3 (int, int, int*, float); char* (*MyPtArray[3]) (int, int, int*, float) { MyFFl, MyFF2, MyFF3 }; // Инициализация массива указателей. Вызов функции (например, MyFF3()) с помощью элемента массива указателей можно осуществить следующим образом: char* MyPointChar = MyPtArray[2](7,7,NULL,7.7);

Указатель на функцию может быть описан как параметр функции: void MonitorF(int,int,int*,float,char*(*)(int,int,int*,float)); // Торжество абстрактного описателя!

И этому параметру можно присвоить значение (значение по умолчанию): void MonitorF(int,int,int*,float,char*(*)(int,int,int*,float)=MyFFl);

Функция, используемая для инициализации последнего параметра, должна быть к моменту инициализации, по крайней мере, объявлена. А вот как может выглядеть определение функции MonitorF: #include <assert.h> /* Заголовочный файл, содержащий макроопределение assert. Это макроопределение преобразуется в условный оператор if. Если в ходе проверки значение условного выражения оказывается равным нулю, то происходит прерывание выполнения программы. */ void MonitorF ( int vail, _ _


int val2, int* pVal, float fVal, char*(*pParF)(int,int,int*,float)

char* pChar; assert(pVal != NULL); assert(pParF != NULL); /*Это всего лишь проверка того, не являются ли указатели пустыми. . .*/ pChar = pParF(vall, val2, pVal, fVal);

Возможные варианты вызова этой функции: int МММ; int* plval = &МММ; /* Указатель plval используется для инициализации третьего параметра. */ МММ • 100; /* А значение объекту, на который настроен указатель plval, может быть изменено в любой момент. */ MonitorF(9,9,pIval,9.9); /* При вызове используем значение указателя на функцию, присвоенное последнему параметру по умолчанию. */ MonitorF(11,11,plval,11.11,MyFF3); /* А теперь передаём адрес новой функции.*/

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

int)

Определим теперь тип указателя на функцию, который будет возвращаться функцией RetumerF(int, int). char*

(*)(int,int,int*,float)

Теперь остаётся правильно соединить обе части объявления, char* (*ReturnerF(int, i n t ) ) ( i n t , i n t , i n t * , f l o a t ) ; Получилась такая вот матрёшка. Функция о двух целочисленных параметрах, возвращающая указатель на функцию, которая возвращает ука-

155


затель на объект типа char и имеет собственный список формальных параметров вида: (int,int,int*,float). Нет предела совершенству! Самое сложное - это объявить прототип подобной функции. Всё остальное очень просто. При определении функции нужно помнить, что она (всего лишь) возвращает указатель на функцию, то есть просто имя функции. Разумеется, эта функция должна быть предварительно объявлена и определена, а её описание должно соответствовать характеристикам функции ReturnerF. Есть такие функции! Здесь их целых три: MyFF1, MyFF2, MyFF3. Приступаем к реализации и параллельно обыгрываем параметры. char* (*ReturnerF(int paraml, int param2))(int,int,int*,float) char* (*PointF) (int,int,int*,float); /* Это всего лишь указатель на функцию. Мы можем себе позволить этот пустяк. */ if (!paraml) return NULL; switch param2 case 1: case 2: case 3: default:

PointF PointF PointF PointF

= = = =

MyFFl; MyFF2; MyFF3; NULL;

break; break; break; break;

return PointF;

Теперь только вызов! Наша функция возвращает адрес функции. И поэтому самое простое - это вызов функции непосредственно из точки возврата функции ReturnerF: i n t v a i l , val2;

MyPointChar = (ReturnerF(vall,val2))(7,7,NULL,7.7);

Всё было бы хорошо, если бы только не существовала вероятность возвращения пустого указателя. Так что придётся воспользоваться ранее объявленным указателем на функцию, проверять возвращаемое значение и только потом вызывать функцию по означенному указателю. Это не намного сложнее: MyPtArray[3] = ReturnerF(vail,val2); if (MyPtArray[3]) {MyPointChar = (MyPtArray[3]) (7,7,NULL,7.7) ;) /* Вот и элемент массива указателей пригодился.*/

Настало время вспомнить о typedef-спецификаторе. С его помощью запись указателя на функцию можно сделать компактнее:

__


typedef char* (*PPFF)

(int,int,int*,float);

Здесь надо представлять всё ту же матрёшку. Замещающий идентификатор PPFF располагается внутри определяемого выражения. И вот новое объявление старой функции. PPFF R e t u r n e r F ( i n t , i n t ) ; В процессе трансляции будет восстановлен исходный вид объявления.

Совместно используемые функции Имя функции в заголовке объявления можно рассматривать как индивидуальную характеристику функции. Однако имени функции для её однозначной идентификации недостаточно. Здесь важен комплекс характеристик функции. При этом спецификация возвращаемого функцией значения актуальна лишь в случае, когда выражение вызова функции является частью более сложного выражения. В пределах области действия данного имени функция однозначно идентифицируется именем в сочетании со списком её параметров. Это обстоятельство позволяет реализовывать механизм совместного использования функций. При объявлении различных функций в C++ можно использовать одни и те же имена. При этом одноимённые функции различаются по спискам параметров. Отсюда становится понятным смысл понятия совместно используемых функций: одни и те же имена функций совместно используются различными списками параметров. У совместно используемых функций имеется ещё одно название. Такие функции называются перегруженными. Смысл этого названия становится понятным из следующей аналогии. В естественном языке одни и те же глаголы могут обозначать различные действия. Например, можно "ходить по комнате", "ходить под парусом", "ходить конём". В каждом из этих контекстов глагол "ходить" употребляется в новом смысле и в буквальном смысле перегружается разными смыслами. Механизм совместного использования заключается в том, что при трансляции исходного кода переименовываются все функции. Новые имена создаются транслятором на основе старых имен и списков типов параметров. Никакие другие характеристики функция при создании новых имён транслятором не учитываются. Приведём пример объявления совместно используемых функций. Предположим, что требуется объявить и определить несколько функций, выполняющих практически одну и ту же работу — выбор максимального значения. При этом каждая функция имеет свои собственные особенности реализации, которые связаны с количеством и типом передаваемых параметров. Очевидно, что каждой функции можно присвоить своё собственное имя, но это (будто бы) затрудняет чтение и понимание текста программы. __


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

max(int,int); max(int*,int); max(int,int*); max(int*,int*) ;

и при этом в процессе трансляции, к имени каждой из объявленных функций будет прибавлена специальная цепочка символов, зависящая от типа и порядка параметров функции. Конкретный алгоритм декодирования зависит от транслятора. В соответствии с представленной в книге Б.Бабэ схемой декодирования имён в Borland C++, декодированные имена четвёрки функций будут выглядеть следующим образом: @max$qii @max$qpii @max$qipi gmax$qpipi Заметим, что при кодировании имён транслятор не использует информацию о типе возвращаемых значений и поэтому пара функций int max(int*,int*); int * max(int*,int*); должна была бы получить одно и то же декодированное имя @max$qpipi, что неизбежно вызвало бы сообщение об ошибке. Причина, по которой при кодировании имён не используется информация о типе возвращаемых значений, заключается в том, что транслятор не всегда способен установить соответствие между выражениями вызова функций и их новыми именами, которые присваиваются определениям и объявлениям функций в ходе трансляции. Функция, которая возвращает целочисленное значение, в программе может быть вызвана без учёта её возвращаемого значения. Если бы транслятор ориентировался на информацию о типе возвращаемого значения, то в этом случае он бы не смог установить соответствие между вызовом и определением (транслятор должен знать, он не должен угадывать). Так что не являются совместно используемыми функции, различающиеся лишь типом возвращающего значения. Также не являются совместно используемыми функции, списки параметров которых различаются лишь применением модификаторов const _ _


или volatile, или использованием ссылки (эти спецификаторы не используются при модификации имён). Кроме того, множество вариантов совместно используемых функций объявляется и определяется внутри одной и той же области видимости имени функции. Объявляемые в различных областях видимости функции совместно не используются. Такие функции скрывают друг друга. Решение относительно вызова совместно используемой функции принимается транслятором и сводится к выбору конкретного варианта функции. Выбор производится в соответствии со специально разработанным алгоритмом, который называется алгоритмом сопоставления параметров. Этот алгоритм обеспечивает сопоставление типа значений параметров в выражениях вызова с параметрами каждого из объявленных вариантов функции. В процессе сопоставления параметров используются, по крайней мере, три различных критерия сопоставления. 1. Точное сопоставление Точное сопоставление предполагает однозначное соответствие количества, типа и порядка значений параметров выражения вызова и параметров в определении функции. // Произвольная функция, которая возвращает целое значение.' int iFunction(float, char * ) ; //Объявление пары совместно используемых функций... extern void FF(char * ) ; //Вариант 1... extern void FF(int); //Вариант 2... //Вызов функции. FF(0) ; F F ( i F u n c t i o n ( 3 . 1 4 , "QWERTY"));

Поскольку нуль имеет тип int, оба вызова сопоставляется со вторым вариантом совместно используемой функции.

2.Сопоставление с помощью расширения типа При таком сопоставлении производится приведение типа значения параметра в выражении вызова к типу параметра в определении функции. Для этого используется расширение типа. Если ни для одного из вызовов точного сопоставления не произошло, то применяются следующие расширения типа: Ш Параметр типа char, unsigned char или short расширяются до типа int. Параметр типа unsigned short расширяется до типа int, если размер объекта типа int больше размера объекта типа short (это зависит от реализации). Иначе он расширяется до типа unsigned int. •

Параметр типа float расширяется до типа double.

//Объявление пары совместно используемых функций. . .

159"


:

extern void FF(char extern void FF(int); //Вызов фун1щии. FF(l a' ) ;

//Вариант 1. //Вариант 2.

Литера 'а' имеет тип char и значение, допускающее целочисленное расширение. Вызов сопоставляется со вторым вариантом совместно используемой функции. 3. Сопоставление со стандартным преобразованием Применяется в случае неудачи сопоставления по двум предыдущим критериям сопоставления. Фактический параметр преобразуется в соответствии с правилами стандартных преобразований. Стандартное преобразование типа реализует следующие варианты сопоставления значений параметров в выражениях вызова и параметров объявления: Ш любой целочисленный тип параметра выражения вызова сопоставляется с любым целочисленным типом параметра, включая unsigned, • значение параметра, равное нулю, сопоставляется с параметром любого числового типа, а также с параметром типа указатель, а значение параметра типа указатель на объект (любого типа) будет сопоставляться с формальным параметром типа void*. //Объявление пары совместно используемых функций... extern void FF(char * ) ; //Вариант 1... extern void FF(float); //Вариант 2... //Вызов функции. FF(0);

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

сопоставление с расширением типа оценивается средним бал-

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

160


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

void void void void

FF(unsgned int); //Вариант 1... FF(char*); //Вариант 2. . . FF(char); //Вариант 3... FF(int); //Вариант 4...

И ещё несколько переменных различных типов... unsigned int iVal; int *p_iVal; unsigned long ulVal;

Рассмотрим вызовы функций. Успешные: FF('a');

//Успешное сопоставление с вариантом 3.

FF("iVal"); //Успешное сопоставление с вариантом 2. FF(iVal);

//Успешное сопоставление с вариантом 1.

Неуспешные: FF(p_iVal); //Сопоставления нет. FF(ulVal); /* Поскольку по правилам стандартного преобразования тип unsigned long, пусть с потерей информации, но всё же может быть преобразован в любой целочисленный тип, сопоставление окажется неуспешным по причине своей неоднозначности. Сопоставление происходит со всеми вариантами функции за исключением функции, имеющей тип char*. */

Решение относительно вызова совместно используемой функции с несколькими параметрами принимается на основе алгоритма сопоставления параметров к каждому из параметров вызова функции. При этом применяется так называемое правило пересечения. Согласно этому правилу, из множества совместно используемых функций выбирается функция, для которой разрешение каждого параметра будет НЕ ХУЖЕ (баллы по шкале соответствия), чем для всего множества совместно используемых функций, и ЛУЧШЕ (баллы по шкале соответствия), чем для всех остальных функций, хотя бы для одного параметра. Например: extern MyFFF(char*, int); extern MyFFF(int, int); MyFFF(0,r a ' ) ;

По правилу пересечения выбирается второй вариант функции. И происходит это по двум причинам:


1. Сопоставление первого фактического параметра вызова функции и первого параметра второй функции оценивается высшим баллом по шкале соответствия параметров, поскольку константа 0 точно сопоставляется с формальным параметром типа int. 2. Второй параметр вызова сопоставляется со вторым формальным параметром обеих функций. При этом литера 'а' имеет тип char и значение, допускающее целочисленное расширение. Таким образом, имеет место сопоставление с помощью расширения типа. Вызов считается неоднозначным, если ни один из вариантов совместно используемых функций не даёт наилучшего сопоставления. Вызов также считается неоднозначным, если несколько вариантов функции дают лучшее сопоставление. Известно, что значением выражения, состоящего из имени функции, является адрес данной функции. Подобные выражения для перегруженных функций недопустимы в силу своей неоднозначности. Транслятор просто не представляет, адрес какой из функций следует определять. Однако всё же определение адреса совместно используемых функций возможно. Это можно осуществить в контексте определения и инициализации указателя на функцию. Необходимую для выбора соответствующей перегруженной функции информацию транслятор получает из спецификации соответствующего указателя. char* MyFFl (int,int,int*,float); char* MyFFl (lot,int*,float); /* Прототипы перегруженных функций. */ char* MyFFl (int keyl, int key2, int* pVal, float fVal) {/* ... */} char* MyFFl (int XX, int* pXX, float FF) {/* ... */} /* Определения перегруженных функций. */ char* (*fPointerl) (int,int,int*,float) = MyFFl; /* Определение и инициализация указателя на первую функцию. Транслятор делает правильный выбор. */ char* (*fPointer2) (int,int*,float); /* Определение указателя на вторую функцию. */ fPointer2 = MyFFl; /* И опять транслятор правильно выбирает соответствующую */ fPointerl(1,2,NULL,3.14); fPointer2(l,NULL,3.14); /* Вызовы функций по указателю.

162

*/

функцию.


По крайней мере, в Borland C++ 4.5, аналогичная инициализация параметров-указателей на функции адресами совместно используемых функций недопустима. Можно предположить, что на этом этапе у транслятора нет ещё полной и достоверной информации обо всех совместно используемых функциях программы. В разделе, посвященном указателям на функции, в примере была приведена функция, у которой в качестве параметра был указатель на функцию. Попытка предварительной инициализации параметра-указателя адресом совместно используемой функции недопустима. Соответствующие ограничения накладываются и на использование значения по умолчанию этого параметра при вызове функции. void MonitorF(int,int,int*,float,char*(*)(int,int,int*,float)=MyFFl); /*Транслятор утверждает, что имя этих функций двусмысленно в контексте инициализации. */ MonitorF(9,9,plval,9.9); /Использование значения параметра по умолчанию также невозможно.*/ void

MonitorF(int,int,int*,float,char*{*)(int,int,int*,float));

MonitorF(11,11,plval,11.11,MyFFl); /* При явном указании имени функции в операторе вызова транслятор однозначно идентифицирует функцию. */

Перегруженные функции и многомерные динамические массивы Многомерный массив в C++ по своей сути одномерен. Операции newQ и deleteQ позволяют создавать и удалять динамические массивы, поддерживая при этом иллюзию произвольной размерности. Деятельность по организации динамического массива требует дополнительного внимания, которое окупается важным преимуществом: характеристики массива (операнды операции new) могут не быть константными выражениями. Это позволяет создавать многомерные динамические массивы произвольной конфигурации. Следующий пример иллюстрирует работу с динамическими массивами. #include <iostream.h> int fdArr(int **, int, int); int fdArr(int ***, int, int, int); // Одноимённые функции. Различаются списками параметров. void main () { int i, j ; /* Переменные (!) для описания характеристик массивов.*/

163"


int diml • 5, dim2 = 5, dim3 = 10, wDim = dim2; /* Организация двумерного динамического массива производится в два этапа. Сначала создаётся одномерный массив указателей, а затем каждому элементу этого массива присваивается адрес одномерного массива. Для характеристик размеров массивов не требуется константных выражений. */ int **pArr = new int*[diml]; for (i = 0; i < diml; i++) pArr[i] = new int[dim2]; p A r r [ 3 ] [ 3 ] = 100; cout « pArr[3][3] « fdArr(pArr,3,3)l

endl;

/* Последовательное уничтожение двумерного массива...*/ for (i = 0; i < diml; i++) delete[]pArr[i]; delete[]pArr; /* Организация двумерного "треугольного" динамического массива. Сначала создаётся одномерный массив указателей, а затем каждому элементу этого массива присваивается адрес одномерного массива. При этом размер (количество элементов) каждого нового массива на единицу меньше размера предыдущего. Заключённая в квадратные скобки переменная в описателе массива, которая, в данном контексте, является операндом операции new, позволяет легко сделать это. */ int **pXArr • new int*[diml]; for (i = 0; i < diml; i++, w D i m — ) pXArr[i] = new int[wDim]; pXArr[3][3] = 100; cout « pArr[3][3] « fdArr(pXArr,3,3);

endl;

/* Последовательное уничтожение двумерного массива треугольной конфигурации... */ for (i = 0; i < diml; i++) delete[]pXArr[i]; delete ПрХАгг; /* Создание и уничтожение трёхмерного массива требует дополнительной итерации. Однако здесь также нет ничего принципиально нового. */ i n t ***ppArr; p p A r r = new i n t * * [ d i m l ] ; f o r ( i = 0 ; i < d i m l ; i + + ) p p A r r [ i ] = new i n t * [ d i m 2 ] ; f o r ( i = 0; i < diml; i++) for (j = 0; j < dim2;

ppArr[i][j] = new int[dim3];

ppArr[l][2][3] = 750; cout « fdArr(ppArr,1,2,3); 164

ppArr[1][2][3] «

endl;


for (i = 0; i < diml; i++) { for (j = 0; j < dim2; j++) delete[]ppArr[i][j];

for (i = 0; i < diml; i++) delete[]ppArr[i]; delete[] ppArr; int fdArr(int **pKey, int indexl, int index2) { cout « pKey[indexl][index2] « endl; ) int fdArr(int ***pKey, int indexl, int index2, int index3) { cout « pKey[indexl][index2][index3] « endl;

Ссылка как параметр функции Рассмотрим простую функцию, которая меняет местами два значения, при условии, что первое значение больше второго. int ChangelfGreater(int* a, int* b) // Параметры - указатели. { int temp; if (*a > *b) { temp = *a; *a = *b; *b = temp; return 1; ) else r e t u r n 0; }

Вызов функции требует явного применения операции взятия адреса: i n t i V a l l = 10, iVal2 = 20; ChangelfGreater(SiVall, SiVal2); // Параметры с операцией в з я т и я а д р е с а .

Функция ChangelfGreater в качестве формальных параметров использует указатели и изменяет значения переменных, находящихся во внешней области видимости, используя адреса объектов, которые передаются в функцию в качестве значений параметров. При анализе текста этой функции нетрудно подсчитать количество вхождений операции разыменования. Эта операция встречается шесть раз на десяти строках. Более сложные функции с большим количеством указателей могут оказаться переполненными этими звёздочками. Возможно, что 165"


чрезмерное количество звёздочек может оказаться дополнительным источником ошибок. В C++ можно упростить код в смысле сокращения количества звёздочек. Для этой цели и используется ссылка — модифицированный вариант указателя. int ChangelfGreater(int£ a, ints Ь) // Параметры — ссылки. { int temp; if (a > b) { temp = a; a = b; b = temp; return 1; ) else return 0; >

Новый вариант функции ChangelfGreater в качестве параметров использует ссылки. Фактически работа ведётся с адресами и в области активации выделяется соответствующая область памяти под адреса, но при этом создаётся иллюзия работы со значениями. Внешний вид упрощается (завязывается новенький бантик), однако смысл происходящего при этом скрывается. int iVall = 10, iVal2 = 20; ChangelfGreater(iVall, iVal2); /* Обман начался в точке вызова функции. Можно очень удивиться, если не посмотреть объявления функции. А если это объявление далеко... И вообще, самое время ещё раз обратиться к приложению, в котором обуждаются так называемые "прочие" характеристики языка. Как там с ними в C++? */

Теперь о том, как должна работать функция говорит лишь тип её параметров в объявлении и определении функции. Функция может возвращать либо значение, либо указатель, либо ссылку. В случае использования ссылок вместо указателей в вызывающей функции также создаётся иллюзия непосредственной работы со значениями. В качестве примера рассмотрим прототипы трёх функций, первая из которых возвращает указатель, вторая возвращает ссылку, третья — целочисленное значение. int* MyFF_l(void); ints MyFF_2(void); int MyFF_3(void);

Определим эти функции, i n t MylntVal; int* MyFF_l(void) 1 returns MylntVal;

int& MyFF_2(void) _


return MylntVal; ) int MyFF_3(void) < return MylntVal; main (void)

int IntVal; int* plntVal = sintVal; /* Теперь нужно следить за расположением звёзд! */ plntVal = MyFF_l(); IntVal - *MyFF_l(); •plntVal = MyFF_2(); IntVal = MyFF_2() ; •plntVal = MyFF_3(); IntVal = MyFF 3();

Функции MyFF_1 и MyFF_3 различаются типом возвращаемых значений и синтаксисом. MyFF_1 возвращает указатель и имеет особенности синтаксиса, характерные для работы с указателями. MyFF_3 присваивает значение переменной IntVal. Это же самое значение может быть через указатель plntVal, инициализированный адресом переменной. Функция MyFF_2 возвращает ссылку, которая является разновидностью указателя. В отличие от MyFF_1 эта функция имеет синтаксис, характерный для функций, которые возвращают значения, а не указатели. Несмотря на внешнюю схожесть контекста, в котором используются функции MyFF_2 и MyFF_3, между ними существует различие, на которое указывает один единственный символ — ptr-операция &, стоящий непосредственно за спецификатором типа возвращаемого значения. Функция MyFF_3 возвращает значение, в то время как функция MyFF_2 возвращает ссылку. При работе с объектами использование ссылок вместо указателей становится ещё более привлекательным. Передача в качестве параметров значений объектов не оправдывается из-за того, что размеры классов, как правило, превышают размеры основных типов. Использование же указателей требует со стороны программиста дополнительного внимания и угрожает появлением дополнительных ошибок.

167


6. Новые типы Перечисления Перечисление - это объявляемый программистом (не входящий в перечень основных типов) целочисленный тип, объединяющий поименованные целочисленные константы. Несколько форм Бэкуса-Наура позволят определить место объявления перечисления среди множества объявлений C++: Объявление : : = /'СгшсокСпецификаторовОб'ъявления ] [СписокОписателей./ СписокСпецификаторовОб'ъявления : : = [СписокСпецификаторовОбъявления СпецификаторОбъявления

;

] СпецификаторОб-ъявления

: : = СпецификаторОсновногоТипа = *****

: :

СпецификаторОсновногоТипа ::= СпецификаторПеречисления •*ш ***** СпецификаторПеречисления ::= envim [ ИмяПеречисления ] { / СписокПеречисления]} СписокПеречисления ::= ЭлеиентПеречисления ::= СписокПеречисления , ЭлементПеречисления ИмяПеречисления ::= Идентификатор ЭлементПеречисления ::= Идентификатор ::= Идентификатор = КонстантноеВыражение

Областью значения типа перечисления является фиксированное множество поименованных констант. Имя перечисления позволяет связать перечисление с конкретным идентификатором. Область область действия имени перечисления определяется расположением перечисления в тексте программы. Элементы перечисления воспринимаются как константы (они и инициализируются константными выражениями) и могут употребляться везде, где используются константные выражения. Таким образом, перечисления позволяют объявлять множества констант целого типа. Такие константы могли бы быть определены с помощью инструкций препроцессора: #define ONE #define TWO

168


#define THREE 3 «define TEN 10

Их удобно использовать в операторах выбора switch, при определении массивов и вообще везде, где необходимо использовать целочисленные константы. В этом случае изменение алгоритма приводит к минимальным модификациям программного кода. Часто бывает достаточно изменения соответствующей инструкции препроцессора. Однако у подобных объявлений имеется один недостаток. Инструкции препроцессора громоздки. Альтернативой подобным инструкциям являются перечисления, которые способны заменить десятки строк инструкций препроцессора одной единственной строкой, подобной строке, приведённой ниже: enum {ONE, TWO, THREE, . . . , TEN} ;

Перед нами один из спецификаторов объявления — так называемый спецификатор перечисления. Для означивания элементов перечисления используется простой алгоритм. Назовём неинициализированным элементом перечисления элемент перечисления, представленный одним идентификатором. Инциализированным элементом перечисления будем называть элемент перечисления, состоящий из идентификатора, операции присвоения и константного выражения. При этом, • если первым элементом перечисления оказывается неинициализированный элемент, то ему по умолчанию присваивается значение 0; • инициализированному элементу перечисления присваивается значение, представленное соответствующим инициализатором; • значение каждого следующего (слева направо) неинициализированного элемента увеличиваются на единицу по сравнению с предыдущим. Имена переменных, и имена элементов перечисления имеют общую область действия имён, в пределах которой возможны конфликты. Например, enum color {red, orange, yellow); enum fruit (apple, orange, kiwi);/* Повторное описание "orange".*/ enum bird {eagle, emu, kiwi); /* Повторное описание "kiwi".*/ int eagle; /*И здесь тоже ошибка! Повторное описание "eagle".*/

В одной и той же области действия имён не допускается объявления двух различных перечислений с одним и тем же именем. Так что добавление к трём ранее приведённым примерам перечислений (разумеется, соответствующим образом исправленным) ещё одного с совпадающим именем перечисления также вызывает сообщение об ошибке: enum color {deepred, deeporange, deepyellow); /* Перечисление color ранее уже встречалось.*/

169


А вот безымянных перечислений в пределах одной области действия имён может быть сколько угодно! Главное, чтобы различались имена элементов перечисления. enum {onel, twol, threel); enum {one2, two2, three2}; enum {опеЗ, two3, three3}; Значения перечислений не должны превышать максимальных допустимых значений для целочисленного знакового типа (эти значения определяются реализацией). Больше не существует никаких ограничений на значения элементов перечислений. Значение перечислителя считается определённым сразу за его инициализатором и потому возможны и такие перечисления: enum (valued, valueO2 = 10, valueO3 = valueO2 + v a l u e d ) ; enum {valuell = 100, valuel2 = valuell + valueO2, valuel3 = valueO2); Каждое объявление перечисления может определять новый целочисленный тип, который отличен от всех других целочисленных типов. Здесь-то и нужно имя перечисления. color MyFirstColor; // Объявлена переменная типа color. Тип данных, определённый на основе перечисления color имеет свой собственный диапазон значений, которые были заданы при определении этого перечисления. И транслятор следит за соблюдением этого диапазона. Правда, не очень строго. Элементы перечисления color сами по себе со своими значениями, а переменная типа color — сама по себе. И значение у нее случайное (или любое). Более того, если очень постараться, переменной типа color можно присвоить любое целочисленное значение в диапазоне допустимых : c o l o r MySecondColor = 32765; Транслятор лишь предупреждает о несовпадении типов, поскольку значение переменной MySecondColor является значением типа int, а не целочисленного типа color. Так что здесь следует воспользоваться операцией явного преобразования (или приведения) типа. И после отключения контроля за типами можно присваивать переменной какое-либо значение! color MySecondColor = (color)32765; color ColorStupid = (color)threel; А теперь всё(!) хорошо! Важно привести к типу!

170


Переменные типа перечисления являются полноценными объектами. Можно определить указатель, воспользоваться операциями взятия адреса и разыменования... c o l o r * pointerForMySecondColor = SMySecondColor; if

( * p o i n t e r F o r M y S e c o n d C o l o r == C o l o r S t u p i d ) . . .

// и т . д .

Но на самом деле тип перечисление введен в язык не для изменения значения объектов этого типа, а для того, чтобы с их помощью менять значения переменных основных типов. i n t i = orange; В результате выполнения этого оператора, переменная i оказывается проинициализированой элементом перечисления color. Целочисленная переменная успешно инициализируется элементом перечисления. Дело в том, что элементы перечисления автоматически преобразуются к целому типу с помощью целочисленного расширения. Здесь не требуется явных преобразований. Арифметические операции, как и операции сдвига и логические операции для перечислимых типов не определены. Определена лишь операция присвоения. И это понятно. Нет никакой гарантии, что в результате каких-либо операций со значениями элементов перечисления результат операции окажется вне множества допустимых значений для данного перечисления. Так что любые операции осуществляются после преобразования (явного или неявного) к целому типу. И последнее, что можно сказать о перечислениях. Объявления в качестве необязательного элемента может содержать список описателей (соответствующие формы Бэкуса-Наура нами уже были рассмотрены). В случае перечисления список описателей позволяет объявить переменные непосредственно в момент объявления перечисления. enum fruit {apple, orange, kiwi) myFruit = orange, Fruit2;

Определены две переменных типа перечисления fruit. Первой переменной присвоено значение по умолчанию равное 1. Значение второй переменной не определено.

Битовые поля Битовые поля являются ещё одной категорией определяемых программистом типов. Битовое поле - это последовательность битов. Минимальная длина битового поля, естественно, равняется 1 (одному биту), максимальная длина зависит от реализации. Битовое поле длинной в восемь бит - не байт. Байт — это минимальная адресуемая область памяти ЭВМ, битовое поле - языковая конструкция. Среди форм Бэкуса-Наура, посвященных объявлению класса, напомним соответствующую БНФ: _


ОписательЧленаКласса ::= [Идентификатор] : КонстантноеВыражение

Вот такой описатель члена класса и задаёт битовое поле. Битовое поле может существовать исключительно как элемент класса. Идентификатор (необязательный!) задаёт имя поля, константное выражение - размеры этого поля в битах. Согласно ранее приведённым БНФ, подобному описателю должны предшествовать спецификаторы объявления. Как известно, они специфицируют тип объявляемого члена класса. В C++ существует ограничения на тип битового поля. Это всегда целочисленный тип. Вполне возможно, что тип знаковый. По крайней мере, в Borland C++, максимально допустимый размер поля равняется длине (количеству бит), объекта соответствующего типа. Рассмотрим пример объявления битового поля: Об'ъявлениеЧленаКласса : : = [ СписокСпецификаторовОб'Ъявления ] [СписокОписателейЧленовКласса]; ::= СпецификаторОбъявления ОписательЧленаКласса; ::= int [Идентификатор] : КонстантноеВыражение; ::= int MyField:5;

А вот как объявления битовых полей выглядят в контексте объявления класса: struct BitsFields { int IntField char CharField int unsigned UnsignedField );

: : : :

1; 3; 3 1;

Неименованное битовое поле также является членом класса. Существует множество ситуаций, в которых оправдано использование неименованных битовых полей. В конце концов, они ничем не хуже неименованных параметров функций. Неименованные поля могут использоваться для заполнения соответствующей области памяти. Если это поле является полем нулевой длины (для неименованного поля возможно и такое), оно может задавать выравнивание следующего битового поля по границе очередного элемента памяти. Хотя и здесь, в конечном счёте, многое зависит от реализации. К неименованным битовым полям нельзя обратиться по имени, их невозможно инициализировать и также невозможно прочитать их значение путём непосредственного обращения к битовому полю. Именованные битовые поля инициализируются подобно обычным переменным. И значения им можно присвоить любые (разумеется, в пределах допустимого для данного типа диапазона значений). Для доступа к соответствующему полю используется операция прямого выбора: __


BitsFields QWE;

QWE.CharField = 100; QWE.IntField = 101; QWE.UnsignedField = 1;

Но фактически значения в битовом поле ограничиваются размерами битового поля. Было поле объявлено размером в три бита - диапазон его значений и будет ограничен этими самыми тремя битами: cout « QWE. CharField « " cout « QWE. IntField « " cout « QWE.UnsignedField « "

" « endl ; " « endl ; " « endl;

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

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


Объявление ::= f СписокСпецификаторовОб'ъявления ] /"СписокОписателей,/ ; СписокСпецификаторовОб'ьявления ::= [СписокСпецификаторовОб'ъявления ] СпецификаторОб-ъявления СпецификаторОб'ъявления : := СпецификаторТипа

СпецификаторТипа ::= СпецификаторКласса ::= УточнённыйСпецификаторТипа = ***** :: УточнённыйСпецификаторТипа ::= КлючевоеСловоКласса ИмяКласса ::= КлючевоеСловоКласса Идентификатор ::= enum ИмяПеречисления КлючевоеСловоКласса ::= union ::= struct ::= class ИмяКласса ::= Идентификатор СпецификаторКласса : := ЗаголовокКласса ( /'СписокЧленовУ } ЗаголовокКласса ::= КлючевоеСловоКласса /'Идентификатор; /"СпецификацияБазы./ КлючевоеСловоКласса ИмяКласса [СпецификацияБаэы] КлючевоеСловоКласса ::= union ::= struct ::= class ИмяКласса ::= Идентификатор

Спецификатор класса представляет то, что называется объявлением класса. Объявление класса начинается одним из трёх ключевых слов: union, struct, class. С ключевого слова struct начинается объявление структуры, с ключевого слова union начинается объявление объединения, с ключевого слова class начинается объявление класса. Различия класса, структуры и объединения мы обсудим позже. Уточнённый спецификатор типа объявляет расположенный за ним идентификатор именем класса. Он обеспечивает неполное предварительное объявление класса и перечисления. Предварительное объявление обеспечивается уточнённым спецификатором типа и является своеобразным прототипом класса или перечисления. Его назначение — сообщение транслятору предварительной информации о том, что существует (должно существовать) объявление класса или перечисления с таким именем. 174


Идентификатор, используемый в контексте уточнённого спецификатора имени, становится именем класса (именем структуры или перечисления). Назначение и смысл необязательного нетерминального символа СпецификацияБазы будут обсуждаться позже, в разделах, посвященных наследованию. Класс считается объявленным даже тогда, когда в нём полностью отсутствует информация о членах класса (пустой список членов класса). Неименованный класс с пустым множеством членов — уже класс! Имя класса можно употреблять как имя (имя типа) уже в списке членов этого самого класса. Класс может быть безымянным. Следующая последовательность операторов объявления class ; /* Неполное предварительное объявление пустого неименованного класса*/ class {}; /* Объявлен пустой неименованный класс.*/ class {}; class {}; class {} ; /* Это всё объявления. Их количество ничем не ограничивается. */ struct {}; /* Структура - это класс, объявленный с ключевым словом struct. Опять же пустой и неименованный.*/

не вызывает у транслятора принципиальных возражений. Разве что предупреждение-констатация факта объявления пустых неименованных классов. На основе класса, пусть даже неименованного, может быть объявлен (вернее, определён) объект-представитель этого класса. В таком контексте объявление неименованного (пусть даже и пустого!) класса является спецификатором объявления. Имена определяемых объектов (возможно с инициализаторами) составляют список описателей. class () Objl, Obj2, Obj3; /* Здесь объявление пустого класса.*/ class {} Obj4, Obj5, Obj6; /* Просто нечего инициализировать.*/ class {} Objl; /* Л Ошибка. Одноименные объекты в области действия

имени.*/

Неименованные классы также можно применять в сочетании со спецификатором typedef (здесь может быть объявление класса любой сложности - не обязательно только пустой). Спецификатор typedef вводит новое имя для обозначения безымянного класса. Описанное имя типа становится его единственным именем. Сочетание спецификатора typedef с объявлением безымянного класса подобно объявлению класса с именем: class MyClass typedef class

{/*...*/}; {/*...*/) MyClass;

175


Правда в первом случае класс имеет собственное имя класса, а во втором - описанное имя типа. Использование описанного имени типа в пределах области действия имени делает эквивалентными следующие определения (и им подобные): class {} Objl; MyClass Objl;

Класс считается объявленным лишь после того, как в его объявлении будет закрыта последняя фигурная скобка. До этого торжественного момента информация о структуре класса остаётся неполной. Если можно ОБЪЯВИТЬ пустой класс, то можно ОПРЕДЕЛИТЬ и объект-представитель пустого класса. Эти объекты размещаются в памяти. Размещение предполагает выделение объекту участка памяти с уникальным адресом, а это означает, что объекты-представители пустого класса имеют ненулевой размер. Действительно, значения выражений sizeof(MyClass) и sizeof(MyObj1) (это можно очень просто проверить) отличны от нуля. А вот пустое объединение не объявляется: union {}; /* Некорректное объявление объединения.

*/

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

::= ОбъявлениеЧленаКласса [СписокЧленов] ::= СпецификаторДоступа : [СписокЧленов]

ОбгьявлениеЧленаКласса

: := [СписокСпецификаторовОбъявления ] [СписокОписателейЧленовКласса]; ::= ОбъявлениеФункции ::= ОпределениеФункции [;] ::= КвалифицированноеИмя;

СписокОписателейЧленовКласса ::= ОписательЧленаКласса ::= СписокОписателейЧленовКласса, ОписательЧленаКласса ОписательЧленаКласса

• Описатель [ЧистыйСпецификатор] /"Идентификатор ] : КонстантноеВыражение

ЧистыйСпецификатор КвалифицированноеИмяКласса ИмяКласса КвалифицированноеИмяКласса ::= ИмяКласса

176


СпецификаторДоступа ::= private ::= protected ::= public

Список членов определяет полный набор членов класса. В этом списке объявляются все члены класса. Таковыми могут быть данные, функции-члены, ранее объявленные классы, перечисления, битовые поля, дружественные функции и даже имена типов. Некоторые из перечисленных понятий нам уже знакомы, о других речь ещё впереди. Этот список не подлежит модификации. Он формируется за один раз. В соответствии с синтаксическими правилами, членами класса могут быть как определения функций, так и их прототипы. Действительно: ОбъявлениеЧленаКласса

: : = /СписокСпецификаторовОбъявления./ [СписокОписателейЧленовКлассаУ; : : = СпецификаторОбт-явления ОписательЧленаКласса; : : = СпеиификаторТипа О п и с а т е л ь ; : : = v o i d Описатель (СхемаОбъявленияПараметров) ; v o i d ff (void);

С другой стороны, Об'ьявлениеЧленаКласса : :— Описатель : : = ff (void)

: : = ОпределениеФункции [; ] (СхемаОб'ъявленияПараметров) {int iVal = 100;)

ТелоФункции

Точка с запятой после определения функции является декоративным элементом. Ни один член класса не может входить в список членов класса дважды. Поэтому определяемая в теле класса функция оказывается без прототипа. Если класс содержит прототип функции в качестве члена класса, функция располагается за пределами класса. Как мы скоро увидим, всё разнообразие объявлений и определений функций-членов транслятор приводит к стандартному виду. Функции-члены могут определяться вне списка членов класса. При определении функции-члена класса за пределами данного класса, в списке членов класса размещается прототип функции-члена. А при определении функции-члена используется квалифицированное имя. Квалифицированное имя состоит из последовательности имён классов, разделённых операциями разрешения области видимости. Эта последовательность имён завершается именем определяемой функции. Последовательность имён классов в квалифицированных именах определяется степенью вложенности объявлений классов. Наличие функций-членов делает объявление класса подобным определению (как и любые функции, функции-члены определяются). Как сказано в Справочном руководстве по C++, "Если бы не исторические причины, объявление класса следовало называть определением класса". __


Данные-члены класса не могут объявляться со спецификаторами auto, extern, register. Ни при каких обстоятельствах не допускается объявление одноименных членов класса (кроме совместно используемых функций-членов, у которых особая система именования). Имена данных-членов должны также отличаться от имён функций-членов. Использование одноимённых функций, констант и переменных в выражениях в пределах одной области действия имён приводит к неоднозначности. Как известно, имя функции, как и имя константы и переменной, является выражением. Если допустить объявление одноимённых переменных, констант и функций, то в ряде случаев просто невозмо будет определить, о чём в программе идёт речь. Объявляемые в классе данные-члены, которые являются представителями классов, должны представлять ранее объявленные классы. Транслятор должен знать заранее о структуре подобных данных-членов. Описатель члена класса в объявлении класса не может содержать инициализаторов (это всего лишь объявление). Назначение спецификаторов доступа будет обсуждаться в разделах, посвященных управлению доступом. Пока будет достаточно знать, что структура и объединения по умолчанию имеют спецификатор доступа public, а класс — спецификатор private. Кроме того, в объявлении класса мы будем явно указывать спецификатор public. В этом случае члены класса оказываются доступны (к ним можно будет свободно обращаться) из любого оператора программы. Если функция-член определяется вне тела класса, в список членов класса включается прототип функции. Определение функции сопровождается квалифицированным именем, которое указывает транслятору на принадлежность определяемой функции-члена классу. Последняя часть квалифицированного имени (собственно имя функции) должна совпадать с именем прототипа функции-члена, объявленного ранее в классе. Подобно определению данных основных типов, в программе могут быть определены объекты ранее объявленного типа. В ходе определения объекта-представителя класса выделяется память для размещения данных-членов класса. При этом непосредственно в этой области памяти размещаются все данные-члены, за исключением данных, объявленных со спецификатором static (об этом спецификаторе будет сказано ниже). Функции-члены и данные-члены класса, в объявлении которых присутствует спецификатор static, называются статическими членами класса. Прочие члены класса называются нестатическими членами класса. Разбор структуры класса осуществляется транслятором за несколько этапов. На первом этапе исследуется список данных-членов класса. Именно этот список и определяет общую структуру класса. До окончания этой стадии разбора класса, а фактически до завершения объявления класса, его имя в объявлении данных-членов может быть использовано лишь в таком контексте, где не используется информация о размерах класса. Это объявления указателей, ссылок и статических членов класса (о них после). __


Таким образом, объект-представитель класса не может быть членом собственного класса, поскольку объект-представитель класса может быть объявлен как член класса лишь после того, как завершено объявление этого класса. Функция-член класса существует в единственном экземпляре для всех объектов-представителей данного класса. Переобъявление и уточнение структуры класса в C++ недопустимо. Серия простых примеров демонстрирует, что можно, а что нельзя делать при объявлении данных-членов класса. class C1 { С1 МуС;

// Это ошибка. В классе не допускается объявле // ния данных членов объявляемого класса. / / А указатель на класс объявить можно.

С1* рМуС; }; Для объявления таких указателей или ссылок на объекты объявляемого класса достаточно неполного предварительного объявления класса. Указатели и ссылки имеют фиксированные размеры, которые не зависят от типа представляемого объекта. class C2; class C1 { Cl* pMyCl; С2* рМуС2; ); С2* PointOnElemOfClassC2; Назначение неполного объявления подобно прототипу функции и используется исключительно в целях предварительного информирования транслятора. Очевидно, что создание объектов на основе предварительного неполного объявления невозможно. Однако это не снижает ценности уточнённого спецификатора. При повторном просмотре объявления класса осуществляется проверка списков параметров в объявлениях функций-членов класса, и определяется размер класса. К этому моменту транслятору становится известна общая структура класса. И потому, как ни странно это выглядит, в классе может быть объявлена функция-член класса, которая возвращает значение объявляемого класса и содержит в списке параметров параметры этого же класса: class C2; class Cl Cl FlfCl parl) {return p a r l ; ) ; 179"


//Объявить данные-члены класса С1 нельзя, а функцию - можно! Cl* pMyCl; С2* рМуС2; // С1 МуС;

С2*

PointOnElemOfClassC2;

Где бы ни располагалась объявляемая в классе функция-член, транслятор приступает к её разбору лишь после того, как он определяет общую структуру класса. В соответствии с формальным определением создадим наш первый класс: СпецификаторКласса ::= ЗаголовокКласса { [СписокЧленов] }; ::= КлючевоеСловоКласса Идентификатор { ОбъявленнеЧленаКласса ОбъявлениеЧленаКласса J; : := class FirstClass { СпецификаторОб'ъявления ОписательЧленаКласса; ОписаниеФункции; }; : := class FirstClass ( СпецификаторОб'ъявления ОписательЧленаКласса; int FirstClassFunction(void);}; ::= class FirstClass ( long int* PointerToLonglntVal; int FirstClassFunction(void);

За исключением квалифицируемого имени синтаксис определения функции-члена класса вне класса ничем не отличается от определения обычной функции: int

FirstClass::FirstClassFunction(void) { int IntVal = 100; return IntVal; };

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

180


нятно. При разборе такой функции-члена транслятор должен иметь представление о структуре класса. Допускается и такая схема расположения объявлений, при которой первыми располагаются неполные объявления классов, следом соответствующие объявления классов и лишь затем определения функцийчленов. Подобные определения мы будем называть отложенными определениями. Позже мы рассмотрим пример программы, в которой отложенный вариант определения функции-члена является единственно возможным вариантом определения. Рассмотрим несколько строк программного кода, демонстрирующих свойства классов. class Classl {int iVal;); class Class2 (int iVal;}; /* Объявление классов Classl и Class2. Эти объявления вводят в программу два новых производных типа. Несмотря на тождество их структуры, это разные типы. */ void ff(Classl); /* Прототип функции с одним параметром типа Classl.*/ void ff(Class2); /* Прототип функции с одним параметром типа Class2. Это совместно используемые (или перегруженные) функции. Об этих функциях мы уже говорили. */ Classl ml; /* Объявление объекта ml типа Classl. */ Class2 m2; /* Объявление объекта m2 типа Class2. */ int m3; ml = т2; ml = тЗ; тЗ = т2; /* Последние три строчки в данном контексте недопустимы. Неявное преобразование для производных типов в C++ невозможно. Транслятор не имеет понятия о том, каким образом его следует проводить. При объявлении классов нужно специально определять алгоритмы преобразования. */ void ff (Classl pp) // Определение функции с параметром рр типа Classl...

void ff (Class2 pp) // Определение второй перегруженной функции.

181


ff (ml) ; //Вызов одной из двух совместно используемых функций... ff (m2) ; //Вызов второй функции...

Ещё один пример объявления класса. class ClassX { ClassX Mm; //Здесь ошибка. Объявление класса ещё не завершено. ClassX* pMm; //Объект типа "Указатель на объект". Всё хорошо. ClassX FF(char char,int i - sizeof(ClassX)); /* Прототип функции. Второму параметру присваивается значение по умолчанию. И напрасно! Здесь ошибка. В этот момент ещё неизвестен размер класса ClassX. Л вот вполне корректное определение встроенной функции. */ int RR (int iVal) int i

sizeof(ClassX); return i;

/* Полный раэбор операторов в теле функции производится лишь после полного разбора объявления класса. К этому моменту размер класса уже будет определён. */

Функции-члены: прототипы и определения При трансляции объявления класса и множества обычных функций транслятор использует различные методы. Следующий пример подтверждает это: // функции-члены класса объявлены без прототипов. class xClass < void fl() (f2<) ;} // Функция-член f1 содержит вызов ещё неизвестной функции £2. void f2() ( } // Следующие функции также объявляются без прототипов, void fl() {£2();> // Здесь будет зафиксирована ошибка. // Транслятор ничего не знает о функции f2() void f2() { } void main() {f1();)

182


Определяемая непосредственно в теле класса функция-член класса оказывается без прототипа. После дополнительного просмотра объявления класса транслятор самостоятельно строит прототип такой функции. При этом определение встроенной функции может быть преобразовано к определению обычной функции-члена с квалифицированным именем и располагаемой вне объявления класса. В результате в классе всё равно не остаётся ни одного определения функции. Все они оказываются за пределами тела класса. Непосредственно в классе остаются лишь прототипы. Построение прототипа функции-члена по её определению при условии нескольких проходов по объявлению класса не самая сложная задача для транслятора. И только после этого, на основе восстановленного списка прототипов функций-членов транслятор приступает к разбору самих функций. Новые алгоритмы разбора порождают дополнительные ограничения на структуру объявления класса. Прототип функции не может располагаться в теле класса вместе с определением функции. Из-за этого в классе не допускается более одного прототипа для каждой функции-члена. Однако допускается поэтапная инициализация параметров: часть из них можно проинициализировать в прототипе, часть непосредственно при определении функции: class QQQ { //int www(int, int); int www(int, int = 0 ) ; ); int QQQ::www(int keyl = 100, int key2){ return key2;}

Интерфейс класса Функции-члены образуют множество операций, которые допустимы над типом класса. Множество функций-членов называют общим интерфейсом класса. Хотя объявления и определения функций-членов подчиняются одним и тем же синтаксически правилам, с точки зрения функциональной полноты интерфейс класса можно условно разделить на четыре подмножества: функции основного подмножества, вспомогательные функции, функции управления и функции доступа. К основному подмножеству относятся функции, определяющие общие характеристики класса. Функции вспомогательного подмножества обеспечивают решение внутренних задач класса. Они не предназначены для непосредственного вызова программистами, использующими данный класс (пользователей класса). Функции управления обеспечивают управление объектами класса: их инициализацию (действия, связанные с выделением памяти под данные-члены и с присвоением данным-членам необходимых начальных зна__


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

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

как глобальная переменная,

Ш как локальная переменная, •

как элемент области активации при вызове функции,

при явном обращении к конструктору,

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


но-ориентированного программирования. В дальнейшем мы не раз будем обращаться к этому классу. class ComplexType { public: double real, imag; /* Действительная и мнимая часть комплексного числа. */ }; /*Сначало было объявление класса, а сейчас - определения объекта.*/ ComplexType GlobalVal; /* Как глобальная переменная.*/ void main () < ComplexType MyVal; /* Как локальная переменная.*/ ComplexType *pVal = new(ComplexType); /* В результате выполнения выражения размещения*/ }

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


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

Конструкторы. Основные свойства Сначала несколько форм Бэкуса-Наура. Объявление : : = Обт.явлениеФункции : : = ОпределениеФункции : ; = ***** ОбъявлениеФункции : : = [ СписокСпецификаторовОб'ьявления ] Описатель ^СпецификацияИсключения]; ОпределениеФункции : : = [ СписокСпецификаторовОб'ъявления ] Описатель /'сЪогИнициализатор] /"СпецификацияИсключения ] ТелоФункции Описатель

: : = Описатель : : = с!Имя

(/'СхемаОб'ъявленияПараметров ])

сШмя : := Имя Класса

Используя это множество БНФ, можно строить объявления весьма странного вида: ОбгьявлениеФункции : : = Описатель; : := Описатель ( ) ; : : = сШмя () ; ~ : : = ComplexType ( ) ;

Объявление... без спецификатора объявления. "186


ОпределениеФункнии ::= Описатель ТелоФункции ::= Описатель () {) : : = сШмя () {} : :== ComplexType () ( }

А это определение. Оно построено в соответствии с правилами построения функций. Не важно, что у него в теле нет ни одного оператора! Важно, что у него нет спецификатора объявления. Именно так и выглядит конструктор, альтернативный тому, который строится транслятором без участия программиста. Множество операторов (возможно пустое), оформленное в виде блока, с заголовком специального вида (ни слова о возвращаемых значениях) - нечто подобное функциичлену. Подобным образом организованная и весьма напоминающая своим синтаксисом обыкновенную функцию последовательность операторов и отвечает за создание объектов данного класса. Отметим одно очень важное обстоятельство. Имя конструктора всегда совпадает с именем класса, членом которого является объявляемый конструктор. Ни одна функция-член класса не может называться именем класса. Ни одна функция-член класса не может быть объявлена и определена без спецификатора объявления. Характерное имя и отсутствие спецификации объявления отличает конструктор от функций-членов класса. Отсутствие спецификаторов объявления означает, что конструктор не имеет абсолютно никакого отношения к вызову и возвращению значений. Конструктор не является функцией. Так что объявления функций-членов класса ComplexType void ComplexType(); ComplexType ComplexType ();

не являются объявлениями конструктора. Для транслятора это всего лишь некорректные объявления функций-членов с пустыми списками параметров. Подобные объявления в классе ComplexType воспринимаются транслятором как ошибки. А вот построенное нами объявление действительно является объявлением конструктора: ComplexType(); И наше определение действительно является определением конструктора: ComplexType(){} Это ничего, что конструктор такой простой, зато он от абсолютно правильный!

187


Как известно, в классе может быть не объявлено ни одного конструктора. В таком случае транслятор без участия программиста самостоятельно строит стандартный конструктор. Не существует классов без конструкторов, хотя классы с автоматически создаваемыми конструкторами, как ни странно, называются классами без конструкторов. В классе может быть объявлено (и определено) несколько конструкторов. Их объявления должны различаться списками параметров. Такие конструкторы по аналогии с функциями называются перегруженными (или совместно используемыми). Транслятор различает перегруженные конструкторы по спискам параметров. В этом смысле конструктор не отличается от обычной функции-члена класса: ComplexType(double rePar, double imPar) ; /* Объявление... */ ComplexType(double rePar, double imPar) (/*...*/) /'Определение...*/

И ещё один вариант конструктора для класса ComplexType - на этот раз с одним параметром (его помощью, например, можно задавать значение мнимой части): ComplexType (double imPar) ; /* Объявление.. */ ComplexType (double imPar) {/*...*/) /*Определение...*/

Здесь мы сознательно опять оставили пустыми тела конструкторов. Необходимо сначала выяснить, какие операторы могут, а какие не могут располагаться в конструкторе. Отсутствие спецификации возвращаемого значения не означает запрета на использование оператора return в теле конструктора. В конце концов, это всего лишь оператор перехода. Но использование этого оператора в сочетании с выражением, задающим возвращаемое значение, например, return NULL;

либо return MyVal;

либо return 125;

и т.д., недопустимо. Возвращаемое значение специфицируется по типу, а как раз про тип возвращаемого конструктором значения в объявлении конструктора ничего и не сказано. Поэтому то, что обычно называется выражением явного вызова конструктора, вызовом, по сути, не является. Часто вообще невозможно сказать что-либо определённое по поводу того, что обеспечивает передачу управления конструктору - так называемое выражение вызова (или обращения к конструктору), либо выражение, которое используется для преобразования типа (постфиксный вари"188


ант выражения преобразования типа). Соответствующая БНФ уже приводилась ранее. Напомним её: ПосфиксноеВыражение : : = ИмяПростогоТипа (/'СписокВьграженийЛ ИмяПростогоТипа и имя конструктора совпадают. Поэтому имя простого типа можно рассматривать как имя конструктора. При вычислении значения выражения приведения для производных типов управление действительно передаётся одноименному конструктору. Без участия конструктора невозможно определить значение соответствующего выражения: (ComplexType) 25; /* В этом случае мы имеем дело с выражением преобразования. При вычислении его значения производится обращение к конструктору ComplexType(double). */ (float) 25; /* Здесь нет никаких обращений к конструктору. Базовый тип float классом не является и конструкторов не имеет. Перед нами оператор, состоящий из выражения приведения (целочисленное значение приводится к типу float). */ float x = float (25); /* В этом случае для определения значения выражения явного преобразования типа, записанного в функциональной форме, также не требуется никаких обращений к конструктору. */ ComplexType (25); /* Казалось бы, здесь мы также имеем дело с функциональной формой выражения явного преобразования типа - оператором на основе постфиксного выражения. Для вычисления значения этого выражения необходимо обратиться к конструктору ComplexType(double). */ На последнее предложение следует обратить особое внимание. Дело в том, что аналогичный оператор на основе постфиксного выражения для основных типов языка C++ воспринимается транслятором как ошибка: float (25); /* Это некорректный оператор! Для любого из основных типов C++ здесь будет зафиксирована ошибка. */ Возникает, на первый взгляд, очень странная ситуация. С одной стороны, мы можем построить операторы на основе любых выражений, в том числе и на основе выражения явного приведения типа. При этом тип, к которому приводится конкретное выражение, не влияет на корректность оператора. Для нового типа принципиально лишь наличие объявления соответствующего класса. С другой стороны, оператор, построенный на основе функциональной формы выражения приведения, оказывается некорректным. Похоже, что перед нами единственный случай, при котором принципиально наличие соответствующего конструктора. _ _


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

(25);

(и ему подобные операторы для основных типов) представляется транслятору объявлением с пропущенным описателем и альтернативной формой инициализатора. Чем-то, напоминающим следующую конструкцию: float

= 25;

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

(char(charVal)) (float(5)) (<int)3.14) (double (0)) (floatO ss i n t ( ) )

(/*.. */) */} */> */} (/*... (/*.*/)

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


Однако, C++ располагает специальными средствами, которые позволяют создавать иллюзию условных выражений с объектамиоперандами производных типов. Чуть позже мы рассмотрим так называемые операторные функции (или перегруженные операции), с помощью которых можно будет всё-таки сформулировать условия, подобные тем, которые формулируются относительно значений основных типов: if (ComplexType() ){/*...*/) if (ComplexType() > 10 SS ComplexType () <= 25 ){/*...*/}

Правда, в данном контексте за символами операций сравнения и даже за выражением "явного вызова конструктора" скрываются так называемые сокращённые формы вызова операторных функций, а не обычные операции C++. А какое условие можно сформулировать в терминах операций, пригодных для работы исключительно со значениями основных типов по поводу значения безымянного объекта-представвителя производного типа, который, к тому же и погибает сразу же после своего рождения? В C++ невозможно сформулировать условие относительно сложного объекта "в целом", используя при этом стандартный набор операций, но легко можно определить значения данных-членов этого объекта. Для этого используется один из вариантов выбора компонента, которая обеспечивает доступ к члену класса: if (ComplexType О .real && !ComplexType () .imag) {/*...*/)

Вот мы и узнали кое-что о свойствах объекта. Правда, объектов в условии целых два. У первого безымянного объекта мы поинтересовались значением данного-члена real, после чего он благополучно отошёл "в мир иной", у второго объекта выяснили значение данного-члена imag. Выражения вызова функций типа void так же недопустимы в контексте условия, поскольку функции void "возвращают" пустые значения. Например, void MyProc(); void MyProc () {/*...*/} if (MyProcO) {/*...*/} /* Здесь ошибка */ for ( ; MyProcO; ) {/*...*/} /* Здесь ошибка */ if (ComplexType ОН/*...*/) /* Это тоже ошибка */

Выражение явного преобразования типа можно расположить в сопровождаемом инициализацией определении или справа от символа операции присвоения в операторе присвоения. ComplexType MyVal = ComplexType (25); ComplexType MyVal = (ComplexType ());

191


MyVal = ComplexType (); MyVal = ComplexType (25); MyVal = (ComplexType) 25;

И опять перед нами так называемый явный вызов конструктора. Но, как сказано в справочном руководстве по C++, "явный вызов конструктора означает не то же самое, что использование того же синтаксиса для обычной функции-члена". Конструктор вызывается не для объекта класса, как другие функции-члены, а для области памяти. Для её преобразования ("превращения") в объект класса. Нам ещё предстоит выяснить, как работает операция присвоения с объектами производных типов, в частности, в сочетании с выражением явного преобразования типа, которое приводит к "вызову" конструктора. И если можно ещё как-то представить пустое значение, которое используется для начальной инициализации данных-членов вновь создаваемого объекта, то присвоение пустого значения леводопустимому выражению в принципе невозможно. Поэтому выражение вызова функции с void спецификатором в операторе присвоения недопустимо: int MyVal; MyVal • MyProcO; MyVal = (void)MyProcO ;

/* Ошибка */ /* Ошибка */

И ещё одно сравнение между конструктором и void-процедурой. Поскольку тип void - это всё же тип, мы можем объявить указатель на voidпроцедуру. void MyFunction (void); void (*MyFunctionPointer) (void);

Указатель на функцию можно настроить на адрес конкретной функции. Для этого существует операция взятия адреса: MyFunctionPointer = MyFunction; /* Можно так. */ MyFunctionPointer = &MyFunction; /* А можно и так. */

С конструктором всё по-другому. Мы можем определить адрес создаваемого конструктором объекта. Всё то же выражение явного преобразования типа обеспечивает обращение к конструктору, который создаёт в памяти безымянный объект, чей адрес и определяется операцией взятия адреса: if (SComplexType())

{/*...*/}

Но вот объявить указатель на конструктор и определить адрес конструктора невозможно. Объявление указателя на функцию требует стандартной спецификации типа функции. Операция взятия адреса возвращает значение определённого типа. Конструктор же не обладает стандартной


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

Переопределение конструктора Порождение объекта, каким бы способом оно ни производилось, сопровождается обязательными "регламентными" работами. К ним относится работа по созданию базовой структуры объекта. По мере возможности мы попытаемся в дальнейшем рассмотреть некоторые аспекты этой деятельности. Пока же ограничимся кратким списком этих мероприятий: • инициализация таблиц виртуальных функций (в своё время будет сказано и об этом); • конструирование объектов, представляющих базовые классы (если таковые имеются); • конструирование объектов, представляющих нестатические данные-члены (конечно, если они существуют); • организация информации для поиска виртуальных баз (как бы хорошо уже сейчас понимать, что это такое); Ш

выполнение кода, составляющего тело конструктора. Главный вывод из анализа этого списка заключается в том, что обязательная работа по превращению области памяти в объект не зависит от программного кода, составляющего тело конструктора. Тело конструктора может быть пустым и, тем не менее, конструктор будет исправно порождать объекты. Первый пример этого раздела занимает всего несколько строк. В нём используется объявление уже известного нам класса ComplexType и единственный оператор определения. Периодически мы будем слегка изменять первоначальное объявление класса комплексных чисел, добавляя в этот класс объявления новых членов. К теории комплексных чисел это не имеет никакого отношения. #include <iostream.h> class ComplexType { public: double real, imag; int x; ); void main() (

ComplexType CDwl; cout « "(" « CDwl.real « ", " « CDwl.imag « cout « ", " « CDwl.x « "..." « endl;

"i)" « endl;

__


В результате выполнения оператора определения в локальной памяти создаётся объект-представитель класса ComplexType под именем CDw1. Выполнение программы показывает, что создаваемый транслятором конструктор действительно занимается организацией структуры объекта (в противном случае не было бы возможным обращение к даннымчленам нового объекта), оставляя при этом случайными значения его данных-членов. Присвоение данным-членам объекта каких-либо определённых значений не предусмотрено обязательным перечнем работ по обустройству объекта. Вполне возможно, что из-за случайных значений программа так и не сможет правильно выполняться. При попытке вывода случайных значений CDwi.real и CDwi.imag программа может быть прервана с диагностическим сообщением "Floating Point: Invalid". Конструктор для класса ComplexType строится транслятором самостоятельно. Его код формируется транслятором на стадии генерации кода подобно тому, как строится код, обеспечивающий вызов функции. Таким оразом, доступ к производству регламентных работ по организации объекта в памяти остаётся для программиста закрытым. Однако программисту предоставляются практически неограниченные возможности по модификации списка параметров и тела новых версий конструктора. Первую собственную первую версию конструктора мы расположем непосредственно в классе. Транслятор самостоятельно построит его прототип и перенесёт его определение за пределы класса. class ComplexType

public: ComplexType() { cout « "Здесь ComplexType() конструктор! «

endl;

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


Вопросы организации объекта продолжают оставаться вне пределов нашей компетенции. Подобно коду, который обеспечивает вызов функции, детали этого процесса скрываются от программиста. Правда, с помощью отладчика, в ходе выполнения исполняемого модуля всё же можно проследить за выполнением этой части кода конструктора, но это уже относится к реализации языка, а не к самому языку C++. Следующая модификация кода конструктора касается значений данных-членов порождаемого объекта. Больше никаких случайных значений! CmplexType() < r e a l = 0.0; imag = 0 . 0 ; х = 0;

cout « "Здесь ComplexType() конструктор!" « endl ; } Теперь и члены класса проинициализированы, и программа будет выполняться без ошибок.

Инициализация объекта: параметры и инициализаторы Совместно используемые функции различаются списками параметров. В этом смысле конструкторы подобны функциям. Рассмотрим определение конструктора с параметрами. Мы расположим его за пределами класса. При этом в классе располагается прототип конструктора, а его имя при определении заменяется квалифицированным именем: c l a s s ComplexType { public: ComplexType(double keyReal, double keyImag, i n t keyX);

ComplexType::ComplexType(double double keyImag,

keyReal,

int JceyX)

cout « « « «

"This is ComplexType(" keyReal « .« , " keylmag « "," keyX « " ) " « endl;

real = keyReal; __


imag = keylmag; x = keyX; };

А вот и подходящее определение. Мы расположим его в функции main: ComplexType CDw2(100,100,0,0); /* Создаётся объект типа ComplexType под именем CDw2 с определёнными значениями. */ int iVal(10); /* Аналогичным образом может быть определён и проинициализирован объект основного типа */

Заметим, что к такому же результату (но только окольными путями) приводит и такая форма оператора определения; ComplexType CDw2 = ComplexType(100,100,0,0) ;

И снова мы встречаем случай определения объекта посредством постфиксного выражения. Здесь опять можно говорить о явном обращении к конструктору с передачей ему параметров. Выражения явного приведения типа здесь построить невозможно, поскольку за заключённым в скобочки именем типа должно стоять унарное выражение. Заметим, что не может быть операторов определения переменных с пустым списком инициализаторов: ComplexType CDwl(); // Это ошибка! int xVal(); // Это тоже не определение.

Независимо от типа определяемой переменной, подобные операторы воспринимаются транслятором как прототипы функций с пустым списком параметров, возвращающие значения соответствующего типа. При объявлении и определении функций C++ позволяет производить инициализацию параметров. Аналогичным образом может быть модифицирован прототип конструктора с параметрами: ComplexType(double keyReal = 0, double keylmag = 0, int keyX = 0 ) ;

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


Конструктор, управление которому передаётся в результате выполнения оператора определения без параметров, называется конструктором умолчания. К конструкторам умолчания относятся следующие: •

конструктор, автоматически создаваемый транслятором,

• определяемый программистом конструктор с пустым списком параметров, • рами.

конструктор с проинициализированными по умолчанию парамет-

Внесём ещё одно изменение в текст нашей программы. На этот раз мы добавим спецификатор const в объявление данного-члена класса х: class ComplexType

const int x; }

И опять возникают новые проблемы. На этот раз они связаны с попыткой присвоения значения константе. Как известно, объявление данного-члена класса не допускает инициализации, а для того, чтобы константный член класса в процессе создания объекта всё же мог получить требуемое значение, в C++ используется так называемый ск>гИнициализатор (именно так называется эта конструкция в справочном руководстве по C++ Б.Строуструппа). Мы не будем гадать, в чём заключается смысл этого названия, а лучше заново воспроизведем несколько форм Бэкуса-Наура. ОпределениеФункции

: : = /"СписокСпецификаторовОбъявления/ Описатель I сЪогИнициализатор./ ТелоФункции

с^огИнициализатор : : — : СписокИнициализаторовЧленовКпасса СписокИнициализаторовЧленовКласса : : - ИнициалиэаторЧленаКласса ' [, СписокИнициализаторовЧленовКласса] ИнициализаторЧленаКласса : : = ПолноеИмяКласса ( /'СписокВьгражений}) : : — Идентификатор (/"СписокВыражений ]) ПолноеИмяКласса ::= КвалиФицированноеИияКласса ::= :: КвалифицированноеИмяКлаееа

Для исследования свойств с!огИнициализатора, подвергнем нашу программу очередной модификации. Мы закомментируем все ранее построенные объявления и определения конструкторов и те из операторов определения объектов класса ComplexType, которые содержали значения, определяющие начальные значения данных-членов. И сразу же начинаем определение новых вариантов конструкторов. ComplexType():х(1) {

ТэТ


cout « "Здесь ComplexType():x(" « x « " ) " « endl; }; Перед нами конструктор с йогИнициализатором. Эта конструкция позволяет решать проблемы начальной инициализации константных данных-членов. При работе с данными-членами класса транслятор рассматривает операцию присвоения как изменение начального значения члена. Инициализатор же отвечает непосредственно за установку этого САМОГО ПЕРВОГО значения. В список инициализаторов разрешено включать все нестатические членам класса (объявленным без спецификатора static), но не более одного раза. Так что следующий вариант конструктора будет восприниматься как ошибочный: ComplexType():х(1), х(2) // Ошибка.

Нетерминальный символ ПолноеИмяКласса определяет синтаксис инициализации нестатических объектов так называемого базового класса (об этом позже). В этом случае список выражений как раз обеспечивает инициализацию членов базового класса. Добавим в объявление нашего класса объявление массива. Инициализация массива-члена класса при определении объекта не вызывает особых проблем (здесь следует вспомнить раздел, посвященный массивам-параметрам). Однако в C++ отсутствует возможность инициализации нестатического константного массива-члена класса. Так что можно не стараться выписывать подобные объявления: const int xx[2]; // Бессмысленное объявление.

всё равно массив хх[2] невозможно проинициализировать. Все варианты инициализации константного нестатического массива будут отвергнуты. ComplexType () :хх( 1,2) {/*...*/}; ComplexType () :хх({ 1,21) {/*...*/}; ComplexType () :хх[0] (1) , хх[1] (2) {/*...*/};

Согласно БНФ, в состав инициализатора могут входить только имена или квалифицированные имена. Для обозначения элемента массива этого недостаточно. Как минимум, здесь требуется выражение индексации, которое указывало бы номер элемента массива. И всё же выход из такой ситуации существует. Можно объявить константный указатель на константу, которому в выражении инициализации можно присвоить имя ранее определённого массива: const int DefVal[2] = {1,2}; class ComplexType

111


const int const * px; /* Объявили константный указатель на константу. */ ComplexTypeO :px(DefVal) {/*...*/};

Окольными путями мы всё же достигаем желаемого результата. Константный указатель на константу контролирует константный массив. Услугами инициализатора могут пользоваться не только константные члены, а инициализирующие значения можно строить на основе самых разных выражений. Главное, чтобы используемые в этих выражениях имена располагались в соответствующих областях видимости: ComplexTypeO :px(DefVal) , x(px[0]) , // Транслятор уже знает, что такое рх. real(100), imag(real/25) // И здесь тоже всё в порядке. { // Здесь располагается тело конструктора.

Конструктор копирования Вернёмся к старой версии конструктора (её проще повторно воспроизвести, чем описывать словами) и снова модифицируем main процедуру нашей программы. Мы определяем и инициализируем новый объект, а затем наблюдаем за результатами: ComplexType() { real = 0.0; imag • 0.0; х = 0; cout « "Здесь ComplexTypeO конструктор!" « endl; ) void main() < ComplexType CDwl; CDwl.real=l.5; CDw2.imag=3.14 //А для изменения значения данных членов воспользуемся //операцией обращения к компоненте класса ComplexType CDw2 = CDwl; cout « cout «

"(" « CDwl.real « ", " « CDwl.imag « ", " « CDwl.x « ".." « endl;

"i) " « e n d l ;

cout « cout «

"(" « CDw2.real « ", " « CDw2.imag « ", " « CDw2.x « "..." « endl;

"i) " « endl;

199


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

if (&CDwl != &CDw2) cout « "OK!' « /* Сообщить о разных адресах.*/

endl;

И всё же выполнение этой тривиальной программы приводит к неожиданному результату: создавая два объекта, мы наблюдаем всего одно сообщение о работе конструктора. Остаётся предположить, что за процесс создания объекта с одновременным копированием значений данных-членов другого объекта, отвечает конструктор ещё неизвестного нам типа. Так и есть! Такой конструктор существует и называется конструктором копирозания. Вместе с конструктором умолчания, конструктор копирования входит в обязательный набор конструкторов для любого класса. Реализация механизма копирования значений для транслятора не является неразрешимой задачей. Конструктор копирования всего лишь создаёт копии объектов. Этот процесс реализуется при помощи стандартного программного кода. И построить такой код транслятор способен самостоятельно. Подобно конструктору умолчания, конструктор копирования наряду с уже известной нам формой "вызова" ComplexType CDw2 = CDwl; имеет альтернативную форму вызова: ComplexType

CDw2(CDwl);

Эта альтернативная форма вызова предполагает объявление конструктора с параметрами. Чтобы восстановить структуру заголовка конструктора копирования, мы должны лишь определить тип его параметра. На первый взгляд, здесь всё просто. Е:сли в качестве значения параметра конструктору передаётся имя объекта, значит можно предположить, что тип параметра конструктора копирования соответствует данному классу. Так, в нашем случае, конструктор копирования класса ComplexType должен был бы иметь параметр типа ComplexType. Однако это не так. И вот почему. _ _


В C++ конструктор копирования является единственным средством создания копий объекта. С другой стороны, конструктор копирования — это конструктор, который поддерживает стандартный интерфейс вызова функций. Это означает, что параметры при обращении к конструктору, подобно параметрам функции передаются по значению. Если выражение вызова содержит значения параметров, то в ходе его реализации в области активации функции создаётся копия этих значений. В таком случае, "вызов" конструктора копирования сопровождался бы построением в области активации конструктора копии объекта. Для этого пришлось бы использовать конструктор копирования как единственное средство построения копии объекта. Таким образом, "вызов" подобного конструктора копирования сопровождался бы бесконечной рекурсией. Итак, У КОНСТРУКТОРА КОПИРОВАНИЯ КЛАССА X НЕ МОЖЕТ БЫТЬ ПАРАМЕТРА ТИПА X. Это аксиома. На самом деле, в конструкторе копирования класса X в качестве параметра используется ссылка на объект этого класса. Причём эта ссылка объявляется со спецификатором const. И в этом нет ничего странного. Как известно, выражение вызова функции с параметром типа X ничем не отличается от выражения вызова функции, у которой параметром является ссылка на объект типа X. При вызове такой функции не приходится копировать объекты как параметры. Передача адреса не требует копирования объекта, а значит, при этом не будет и рекурсии. Конструктор копирования - обязательный элемент любого класса. Он также может быть переопределён подобно конструктору умолчания. При этом работа со ссылками в конструкторе копирования не требует явного использования операции разыменования. А спецификатор const (конструктор копирования работает с адресом объекта) предохраняет объектпараметр от случайной модификации в теле конструктора.

Переопределение конструктора копирования Упомянутая нами в предыдущем разделе аксиома о конструкторе копирования имеет одно интересное следствие. В классе X в принципе не может быть объявлено конструктора с ЕДИНСТВЕННЫМ параметром типа X. Это происходит из-за того, что выражение "вызова" такого конструктора просто невозможно будет отличить от выражения "вызова" конструктора копирования. Не бывает совместно используемых функций с неразличимыми выражениями вызова. А определение функций, которым в качестве параметров передаются значения объектов данного класса, возможно. При этом в реализации вызова подобных функций конструкторам копирования отводится значительная роль. Они отвечают за создание копии объекта в области активации вызываемой функции. 201


Итак, конструктор копирования предназначается для копирования объектов. Он также участвует в реализации механизма передачи параметров при вызове функций. Мы можем построить собственную версию конструктора копирования. Наш новый встроенный конструктор копирования сообщает о собственном присутствии. Пока этого вполне достаточно. ComplexType(const ComplexTypeS ctVal) < cout « "ComplexType(const ComplexTypefi KeyVal..." « endl; ) ; // Л В теле класса ComplexType имеем право на эту точку с запятой...

Несмотря на пустое тело, перед нами настоящий конструктор копирования. Всякий конструктор, параметром которого является ссылка на объект-константу, представляющий данный класс, называется конструктором копирования. Даже если этот конструктор ничего не копирует. Переопределение конструктора копирования является чрезвычайно ответственным поступком. Явное определение конструктора копирования вызывает изменения в работе программы. Пока мы не пытались переопределить конструктор копирования, исправно работал конструктор, порождаемый транслятором. Этот конструктор создавал "фотографические" копии объектов, то есть копировал значения абсолютно всех данных-членов, в том числе и ненулевые значения указателей, представляющие адреса динамических областей памяти. С момента появления переопределённой версии конструктора копирования, вся работа по реализации алгоритмов копирования возлагается на программиста. Переопределённый конструктор копирования может вообще ничего не копировать (как и наш новый конструктор). Впрочем, заставить конструктор копирования копировать объекты совсем несложно: ComplexType(const ComplexTypeS ctVal) { cout « "Здесь конструктор копирования" « real • ctVal.real; imag = ctVal.imag; x = ctVal.x; )

endl;

Но конструктор, создающий подобные копии объектов, скорее всего, окажется непригодным для работы с объектами, содержащими указатели или ссылки. Не самым удачным решением является ситуация, при которой данные-члены типа char*, их нескольких объектов, возможно расположенных в различных сегментах памяти, в результате деятельности конструктора копирования настраиваются на один и тот же символьный массив. В переопределяемом конструкторе копирования (а в классе он может быть только один) можно реализовывать разнообразные алгоритмы распределения памяти. Здесь всё зависит от программиста. Предлагаемый ниже пример иллюстрирует процесс корректного копирования объектов с выделением памяти. Помимо незначительных изме"202


нений в структуре класса (объявлен указатель на строку InfoString), этот пример от всех предыдущих отличается тем, что он реализован в системе программирования Microsoft Visual C++ 6.0. Э,то обстоятельство можно было бы и не упоминать, если бы не специфическое для Microsoft C++ имя библиотечной функции дублирования строки. #include <iostream.h> #include <string.h> class ComplexType { public: double real, imag; /* Действительная и мнимая часть комплексного числа. */ ComplexType(double realKey, double imagKey, char* InfoKey); ComplexType(const ComplexTypeS KeyVal); /* Указатель на строку. */ char* InfoString; ComplexType::ComplexType (double realKey, double imagKey, char* InfoKey) real = realKey; imag = imagKey; InfoString = _strdup(InfoKey); /* функция _strdup позволяет создать дубликат строки. При этом специально выделяется фрагмент памяти, в который и осуществляется посимвольное копирование строки. В случае успешного дублирования строки, функция возвращает указатель на размещённый в памяти дубликат. */ cout «

"ComplexType(double, double, char*)..." «

endl;

ComplexType::ComplexType(const ComplexTypeS KeyVal) { cout « "Здесь конструктор копирования" « endl; real = KeyVal.real; imag = KeyVal.imag; InfoString = _strdup(KeyVal.InfoString); /* Та же самая функция используется для корректного копирования объектов.*/

void main () { ComplexType cVall(0.0,0.0, "XXX");

203"


cout « cVall.InfoString « ComplexType oVal2 = cVall; cout « cVal2.InfoString «

endl; endl;

if (cVall.InfoString == cVal2.InfoString) cout « "cVall.InfoString == cVal2.InfoString" « else cout « "cVall.InfoString != cVal2.InfoString" «

endl; endl;

Конструкторы преобразования и преобразующие функции Для основных типов в C++ действуют правила неявного преобразования типов, а также реализованы операции явного преобразования. Следующая последовательность операторов, независимо от результата выполнения, будет восприниматься транслятором без каких-либо возражений: i n t xx; f l o a t zz = 123456.123456; xx = i n t ( z z ) * 25; Операция явного преобразования типа является гарантией корректности кода. Программист знает, что делает и отвечает за возможные последствия. На самом деле, транслятор не возражает и против такого оператора: xx = z z; В этом случае также можно предположить, что происходит при выполнении этого оператора. А вот попытка присвоить объекту типа ComplexType целочисленное значение, обречена на неудачу. ComplexType CDw4 = 10; ComplexType CDw5; CDw5 = 25;

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


ComplexType(const int& iVal) { cout « "Это конструктор преобразования" « endl ; real = iVal; imag = iVal; }; Задание алгоритма преобразования - личное дело программиста. В нашем случае целое число преобразуется в комплексное. При этом реальная и мнимая части получают одинаковые значения. Главное — это то, что после того, как транслятору становится известен конструктор преобразования, операторы, описанные ранее, приобретают новый смысл. Теперь определение ComplexType CDw4 = 10;

представляется сокращённой формой "вызова" конструктора преобразования. Его можно переписать таким образом: ComplexType CDw4 = ComplexType(10); ComplexType CDw4(ComplexType(10)); ComplexType CDw4(10);

А оператор присвоения CDw5 = 25; принимает следующий вид: CDw5 = ComplexType(25);

В результате выполнения выражения явного преобразования вызывается конструктор преобразования, который создаёт временный безымянный объект, значение которого посредством операции присваивания (у каждого класса она своя) присваивается объекту CDw1. После этого временный объект уничтожается. Всё очень просто и красиво. Таким образом, проблема преобразования значения к типу, соответствующему данному классу (к производному типу) разрешается после определения конструктора преобразования. Преобразование значения производного типа (типа, соотвветствующего данному классу) к основному типу обеспечивается специальными функциями приведения или преобразующими функциями. Объявление этих функций отличается характерным синтаксисом. Перечислим основные особенности синтаксиса этих функций: • функции приведения не имеют спецификатора возвращаемого значения; Ш объявление функции приведения начинается с ключевого слова operator; 205


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

ОпределениеФункции *****

ОпределениеФункции : : = [СписокСпецификаторовОб'ъявления ] Описатель / Ъ ^ г И н и ц и а л и з а т о р 7 Т е лоФункции = Описатель ТелоФункции = Описатель (СхемаОбт.явленияПараметрове тров) ТелоФункции = Описатель (/"СхемаОбъяв ленияПараметров ] [...]) ТелоФункции = ИмяОписатель () ТелоФункции = Имя () ТелоФункции = ИмяФункцииПриведения () ТелоФункции

И ещё несколько форм, посвященных нетерминалу ИмяФункцииПриведения: ИмяФункцииПриведения ::= operator ИмяПриведённогоТипа ИмяПриведённогоТипа ::= СписокСпецификаторовТипа [ptrOnepanHH] СписокСпецификаторовТипа ::= СпецификаторТипа /'СписокСпецификаторовТипа ]

Наконец, Объявление ::= operator СпецификаторТипа [СписокСпецификаторовТипа

J fptrOnepaigre ] ;

ОпределениеФункции ::= operator СпецификаторТипа [СписокСпецификаторовТипа] [р^Операция ] ТелоФункции

Таков синтаксис объявления и определения функций приведения. Мы объявляем функцию приведения к типу double непосредственно в теле нашего класса ComplexType (при этом выбор алгоритма преобразования остаётся личным делом программиста): class ComplexType

operator double() "206


return imag;

Объявляем переменную z и объект CDw6: double z; ComplexType CDw6(1,255); После этого следующие операторы присвоения оказываются эквивалентными в деле присвоения переменной z значения мнимой части объекта CDw6. z = CDw6.operator doublet); /* operator double - имя функции приведения */ z « CDw6; z = double(CDw6); z = (double)CDw6; Наличие в классе подобной функции приведения придаёт смысл условным операторам вида if (ComplexType()) {/* */) if (ComplexType<) > 3.14) {/*

*/)

Проблемы включения подобных выражений ранее уже обсуждались нами в разделе, посвященном основным свойствам конструкторов. Явным образом объявленной в классе функции приведения к одному из основных типов вполне достаточно для того, чтобы транслятор самостоятельно обеспечил преобразование типа, в результате которого появилась бы возможность для определения соответствующего преобразованного значения порождаемого объекта. На очереди функции приведения из класса в класс. Нам потребуется объявить ещё один класс. Мы объявим класс с максимально простой структурой: class r r r { public: double a , b ; >; А вот как будет выглядеть конструктор приведения к типу rrr для класса ComplexType (мы сознательно опускаем детали его реализации): ComplexType (const rrrS rVal) { ) При объявлении параметра мы используем ссылку на объект. Здесь можно было бы организовать передачу параметров по значению. Но пере_ _


дача параметра по ссылке всегда предпочтительнее передачи параметра по значению. Построим функцию приведения из класса ComplexType в класс ггг. По традиции, это будет встроенная функция. Конечно же, что и как преобразовывать - личное дело каждого. class ComplexType operator rrr()

I

rrr temp; temp.a = real; temp.b • imag; return temp; /* Функция приведения - это всё-таки Функция! */ }

Мы определяем переменную - представитель класса ггг: r r r v; После чего наблюдается полная эквивалентность тройки операторов: v = CDw6.operator rrr(); v = (rrr)CDw6; v = rrr (CDw6) ;

А главное, что функция приведения и такой оператор делает эквивалентным предыдущей тройке операторов: V = CDw6; И здесь нет ничего удивительного, поскольку в C++ допускается неявное преобразование типов. Операторные функции преобразования всего лишь позволяет расширить диапазон типов, над которыми можно осуществлять это самое неявное преобразование. Достаточно определить алгоритм преобразования и представить его в виде операторной функции. Всё остальное транслятор способен проделать самостоятельно! Но во всём следует соблюдать меру. Например, попытка определение конструктора преобразования в классе rrr в контексте нашей программы (при условии, что в программе опеделна функция приведения для класса ComplexType) недопустимо. Это не означает, что для класса rrr в принципе невозможно построить конструктор преобразования. После предварительного объявления класса ComplexType он, конечно же, может быть построен (здесь можно даже не вдаваться в детали реализации этого конструктора): rrr (const ComplexType& ctVal)

"208


} Однако его объявление вызовет возражения со стороны транслятора, причина которых заключается в том, что транслятор просто не сможет определить, о чём в данном контексте идёт речь. Дело в том, что оператор присвоения v = CDw6; одновременно может обозначать как неявный "вызов" конструктора преобразования класса ггг, так и вызов функции приведения класса ComplexType.

Указатель this Продолжаем определение класса ComplexType. Теперь объявим и определим функцию-член PrintVal, которая будет выводить значение чисел-объектов. Прототип функции разместим в классе: void PrintVal() ; При определении функции используется квалифицированное имя:

void ComplexType::PrintVal() { cout « "(" « real « ", " « imag « cout « ", " « x « "..." « endl; }

"i)" « endl;

Значения данных-членов объекта выводятся при выполнении выражения вызова функции PrintVal:

CDwl.PrintVal() ; Объекты класса имеют свои собственные экземпляры данных. Данные имеют свои собственные значения. Вместе с тем, все объекты используют единый набор функций, с помощью которого можно получить доступ к значениям данных-членов во всех объектах класса. Среди операторов функции-члена PrintVal() нет ни одного оператора, который позволял бы определить, какому именно объекту принадлежат данные. И, тем не менее, вызов этой функции для каждого из определённых и различным образом проинициализированных объектов, в том числе и для безымянного объекта, который создаётся в результате непосредственного "вызова" конструктора: ComplexType(0.0,0.0,

1).PrintVal();


а также вызов функции для объекта, адресуемого указателем: pCD->PrintVal(); сопровождается сообщением о значениях собственных данных-членов. Напомним, что собственные данные объектов, как и те функции-члены класса, с которыми мы уже успели познакомиться, считаются нестатическими данными-членами и функциями-членами класса в их объявлениях отсутствует спецификатор static. Способность автоматически определять принадлежность данных конкретным объектам свойственна любой нестатической функции класса. Объекты являются "хозяевами" нестатических данных и любая нестатическая функция-член класса способна распознавать распознавать "хозяйские" данные. Алгоритм распознавания хозяина данных не очень сложен. Здесь проблема заключается совсем в другом: алгоритм распознавания должен быть реализован для каждой нестатической функции-члена класса. Он используется везде, где производится обращение к данным-членам объектов, а это означает, что на программиста может быть возложена дополнительная обязанность по кодированию. Несколько ОБЯЗАТЕЛЬНЫХ строк практически для каждой функции-члена? Да никогда... К счастью, C++ освобождает программистов от утомительной и однообразной работы кодирования стандартного алгоритма распознавания. В C++ вообще многое делается без участия программистов. Функциичлены определяются в классе как обычные функции. Транслятор переопределяет эти функции, обеспечивая при этом стандартными средствами связь между объектами и их данными. Эта связь реализуется благодаря специальному преобразованию исходного кода программы. Мы опишем это преобразование, условно разделив его на два этапа. На первом этапе каждая нестатическая функция-член (без спецификатора объявления static) преобразуется в функцию с уникальным именем и дополнительным параметром — константным указателем на объект класса. Затем преобразуются обращения к нестатическим данным-членам в операторах функции-члена. Они переопределяются с учётом нового параметра. В C++ при подобном преобразовании для обозначения дополнительного параметра-указателя (константного указателя) и постфиксного выражения с операцией косвенного выбора компонента "->" для обращения к нестатическим данным-членам используется одно и то же выражение — так называемое первичное выражение this. Вот как могла бы выглядеть функция-член PrintVal после её переопределения: void ComplexType: :ComplexType_PrintVal (ComplexType const *this)

I

cout « cout «

210

"(" « this->real « "," « this->imag « " i ) " « endl; "," « x « "..." « endl;


В контексте объявления нового параметра первичное выражение this можно рассматривать как стандартное имя для обозначения параметра-указателя. На втором этапе преобразуются вызовы функций-членов. К списку значений параметров выражений вызова добавляется выражение, значением которого является адрес объекта, "от имени которого" вызывается функция. Это вполне корректное преобразование. Дело в том, что нестатические функции-члены вызываются для конкретного объекта. И потому не составляет особого труда определить адрес этого объекта. Например, вызов функции-члена PrintVal() для объекта CDw1, который имеет вид CDwl.PrintVaK) ; после преобразования принимает вид: ComplexType_PrintVal(fiCDwl); А вызов функции-члена безымянного объекта, адресуемого указателем pCD pCD->PrintVal();

преобразуется к виду ComplexType_PrintVal(&(*pCD)); что эквивалентно следующему оператору: ComplexType_PrintVal(pCD); Первый (и в нашем случае единственный) параметр в вызове новой функции является адресом объекта. В результате такого преобразования функция-член приобретает новое имя и дополнительный параметр типа указатель на объект со стандартным именем this, а каждый вызов функции-члена приобретает форму вызова обычной функции. Причина изменения имени для функций-членов класса очевидна. В разных классах могут быть объявлены одноименные функции-члены. В этих условиях обращение к функции-члену класса непосредственно по имени может вызвать конфликт имён: в одной области действия имени одним и тем же именем будут обозначаться функции-члены разных классов. Стандартное преобразование имён позволяет решить эту проблему. Указатель this можно использовать в нестатической функции без его предварительного специального объявления. В частности, операторы функции ComplexType::PrintVal() могут быть переписаны следующим образом: _ _


void CoraplexType::PrintVal() { cout « "(" « this->real « *," « cout « "," « x « "..." « endl; }

this->imag «

"i)" « endl;

Употребление необъявленого this указателя не вызывает у транслятора никаких возражений, что свидетельствует об эквивалентности старого и нового вариантов функции. В этом случае указатель this нельзя рассматривать как имя (имя вводится объявлением). В этом контексте this указатель считается первичным выражением. Напомним, что имя, как и первичное выражение this являются частными случаями выражения. В ряде случаев при написании программы оправдано явное использование указателя this. При этом выражение this представляет адрес объекта, а выражение •this представляет сам объект: this->BbIPA3KEHHE (*this).ВЫРАЖЕНИЕ

Здесь нетерминальный символ ВЫРАЖЕНИЕ в сочетании с операциями прямого и косвенного выбора обозначает член класса (функцию или данное). Эти выражения обеспечивают доступ к членам уникального объекта, представленного указателем this. Такие выражения позволяют читать и изменять значения данных, а также вызывать функции-члены. Следует помнить о том, что this указатель является константным указателем. Это означает, что любая попытка изменения его значения в теле функции (например, перенастройка указателя: this-ь-ь) недопустима. Указатель this уже в момент вызова функции настраивается на адрес объекта. Однако в некоторых версиях языка this указатель объявляется без спецификатора const и, естественно, допускает перенастройку. Более того, присваивание этому указателю значения NULL является одним из способов уведомления о неудаче при выполнении операторов функции. При описании this указателя мы не случайно подчёркивали, что этот указатель используется только для нестатических функций-членов. Использование этого указателя в статических функциях-членах класса (о них речь впереди) не имеет смысла. Дело в том, что эти функции в принципе не имеют доступа к нестатическим данным-членам класса. В объявлении нестатической функции this указателю можно задавать дополнительные свойства. В частности, возможно объявление константного this указателя на константу. Синтаксис языка C++ позволяет сделать это. Среди БНФ, посвященных синтаксису описателей, есть и такая форма: _ _


Описатель : : = Описатель (СхемаОб'ъявленияПараметров) /СписокСУОписателей ] ; ; = +•+•* СУОписатель : : = c o n s t

Так что небольшая модификация функции-члена PrintVal, связанная с добавлением стОписателя const: void PrintVal()

const;

в прототипе и void ComplexType::PrintVal() const

в определении функции защищает от модификации значения всех образующих данный объект нестатических членов класса. Подобным образом объявленная функция позволяет лишь читать соответствующие значения объекта. С\/Описатель const в заголовке функции заставляет транслятор воспринимать операторы, которые содержат в качестве леводопустимых выражений имена данных-членов, возможно, в сочетании с this указателем, как ошибочные. Например, следующие операторы в этом случае оказываются недопустимы. this->x = 125; real = imag*25; imag++; Заметим также, что this указатель включается также в виде дополнительного параметра в список параметров конструктора. И в этом нет ничего удивительного, поскольку его значением является всего лишь область памяти, занимаемая объектом.

Деструкторы Деструктор - множество операторов, по своему назначению обратное конструктору. Деструктор превращает объект во фрагмент памяти. Если вспомнить, какой объём работ выполняет конструктор в ходе превращения области памяти в объект (внушительный список малопонятных мероприятий), можно предположить, что деструктору также найдётся работа. Ни один класс не обходится без собственной версии деструктора. Отсутствие объявления деструктора в классе означает, что транслятор самостоятельно строит деструктор. _ _


Синтаксис определения деструктора имеет свои особенности. У него отсутствует спецификация возвращаемого значения, обязательный пустой список параметров, а в качестве описателя используется вариант с!Имени, представляющий собой имя класса, перед которым ставится символ '"•' (этот символ является частью имени деструктора): ОпределениеФункции ::= Описатель ТелоФункции ***** : : = Описатель

: := Описатель

::= аимя ***** ::=

() ТелоФункции

Пустые скобки за описателем согласуются с правилами синтаксиса языка. В этих скобках располагается список объявлений параметров и один из вариантов этого списка, как известно, имеет следующий вид: СхемаОб-ъявленияПараметров ( СписокОб'ъявленийПараметров J [...]

При разборе нетерминала сДИмя , обходимся одной БНФ: с!Имя : : = -ИмяКласса

В результате получаем БНФ, которая описывает синтаксис встроенного варианта определения деструктора: ~ИмяКласса () ТелоФункции [;] Соответствующие формы Бэкуса-Наура для прототипа и обычного (не встроенного, объявляемого с квалифицированным именем) определения деструктора — дело техники. Здесь нет ничего нового. Особенности синтаксиса определяются особыми свойствами деструктора: Ш в заголовке объявления и определения деструктора нет и не может быть спецификаторов возвращаемого значения. Это означает, что невозможно объявить указатель на деструктор и невозможно определить адрес деструктора; • у деструктора всегда пустой список параметров. Так что в классе по вполне понятным причинам может быть объявлен всего один деструктор; • в деструкторе можно использовать оператор return. При этом не допускается никаких возвращаемых значений; Ш обычно деструктор вызывается автоматически. Это происходит в следующих случаях: _


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

кроме того, деструктор может быть вызван явным образом.

В качестве примеров явного "вызова" деструктора рассмотрим следующие операторы: CDw.-ComplexType(); // Разрушение объекта CDw. pCDw->~ComplexType(); /* Разрушение объекта, расположенного по адресу, представленному указателем pCDw. */ this->~ComplexType(); /* Саморазрушение объекта, представленного this указателем.*/

Все рассмотренные нами варианты явного "вызова" деструкторов характеризуются обязательным включением в выражения вызова операций прямого . или косвенного -> выбора. Если деструктор вызывается в теле нестатической функции непосредственно для объекта, вызывающего данную функцию, в выражении "вызова" деструктора должен явно присутствовать this указатель. Это сделано специально, чтобы можно было по контексту различить символ унарной операции ~ от первого символа имени деструктора. Мы ещё вернёмся к анализу контекста явного "вызова" деструктора. Примечательно, что вместо имени класса (имя класса всего лишь идентификатор) в выражении явного "вызова" деструктора может быть использовано имя основного типа. Рассмотрим, например, такую последовательность операторов: int QQQ = 100; /* Определение и инициализация объекта. */ int *pQQQ = SQQQ; /* Настройка указателя. */ pQQQ->int::~int(); /* Это уж точно не "вызов" деструктора... */

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


typedef i n t NonComplexType; NonComplexType NCD1 = 0; NonComplexType *pNCD = new NonComplexType; ComplexType ComplexType

CD1 = 0; *pCD = new ComplexType;

Тем более что обращение к конструктору всегда маскируется. В этом случае разрешение обращения к несуществующему деструктору кажется предпочтительнее дискриминации по именам, обозначающим типы: pNCD->NonComplexType::-NonComplexType(); pCD->ComplexType::-ComplexType(); Правда, "обращение" к деструктору допускается лишь в выражениях с указателями и по полному квалифицированному имени (на это также существуют свои причины). Во-вторых, "обращение" к несуществующему деструктору связано с использованием шаблонов (о них речь впереди). Используя шаблоны, программист может разрабатывать программу, не зная, существует ли у данного типа (класса) деструктор. Достойны внимания и такие простые операторы: i n t Val_l = i n t ( ) ; i n t Val_2 = ~ i n t ( ) ; Никаких конструкторов и деструкторов здесь нет. В первом случае мы имеем определение переменной с инициализатором на основе выражения преобразования (пустое значение пиводится к типу int), во втором случае инициализатор представляет собой сочетание выражения преобразования с унарной операцией инвертирования. Значения этих выражений можно вывести на экран: cout « cout «

int(); ~int() ;

Деструктор может быть переопределён: -ComplexType() { c o u t « "Это новый д е с т р у к т о р " « endl; } Подобно конструктору, переопределённый деструктор предполагает скрытый от программистакод, который обеспечивает проведение необходимых работ по расформированию объекта. В теле деструктора, как и в обычных функциях, могут располагаться разнообразные операторы. По 216


известным причинам в деструкторе не допускается операторов перехода, возвращающих какие-либо значения. В деструкторе обычно располагаются операторы, которые отвечают за процесс разрушения объекта: операторы, связанные с освобождением динамической памяти и разнообразных системных ресурсов (например, закрытие файлов). Ранее мы достаточно подробно обсудили свойства this-указателя. Но самое интересное заключается в том, что this указатель в качестве первичного выражения может использоваться в деструкторе, у которого вообще нет и не может быть никаких параметров! В контексте деструктора this действительно всегда выступает в качестве первичного выражения, то есть выражения, которое не нуждается в предварительном объявлении. Конструктор превращает фрагмент памяти в объект. Деструктор выполняет обратную работу. В момент обращения к деструктору объект уже существует. Поэтому возможно саморазрушение объекта. Обращение к деструктору "от имени" объекта мало чем отличается от обычного вызова функции-члена класса. А вот обращение к конструктору "от имени" объекта-представителя данного класса не возможно: CDwl.ComplexType(CDwl); // Неудачная попытка обращения к конструктору копирования. / / Н Е ОБЪЕКТ ДЛЯ КОНСТРУКТОРА, А КОНСТРУКТОР ДЛЯ ОБЪЕКТА!

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

<iostream.h> <string.h> <malloc.h>

class ComplexType { - public: double real, imag; /* Действительная и мнимая часть комплексного

числа. */

ComplexType(double realKey, double imagKey, char* InfoKey); ComplexType(const ComplexTypeS KeyVal); -ComplexType(); /* Указатель на строку. */ char* InfoString; }; ComplexType: : ComplexType (double realKey, double imagKey, char* InfoKey) { real = realKey; imag = imagKey; InfoString = _strdup(InfoKey); cout «

"ComplexType(double,

double, char*)..." «

endl;

217"


ComplexType::ComplexType(const ComplexTypeS KeyVal) { real = KeyVal.real; imag = KeyVal .imag; Inf oString = _strdup (KeyVal. Inf oString) ; cout «

"ComplexType(const ComplexTypeS KeyValJ..." «

endl;

ComplexType::-ComplexType() { free (InfoString); // Освобождение области памяти, выделенной ранее под строку. cout «

"-ComplexType()..." «

endl;

void main () { ComplexType cVall(0.0,0.0, "XXX"); cout « cVall. Inf oString « endl; ComplexType cval2 = cVall; cout « cVal2.InfoString « endl; cVall.-ComplexType () ; // Деструктор для второго объекта вызывается автоматически.

Конструкторы и деструкторы: заключительные замечания Мы закончили описание конструкторов и деструкторов - важных элементов любого класса. Хотя в дальнейшем нам придётся ещё несколько раз обратиться к этому вопросу, главное уже позади. И всё же следует сделать несколько замечаний. Причина сокрытия кода регламентных работ по созданию и уничтожению объектов в конструкторах и деструкторах связана с тем, что работа по распределению глобальной, локальной и динамической памяти чрезвычайно ответственна и достаточно сложна. С другой стороны, эта работа стандартна и однообразна. Это основания языка. Содержание этого процесса просто обязано быть скрытым от программиста подобно тому, как скрыт программный код, который реализует, например, операции индексации, сравнения, сдвига, вызова функций. По той же причине скрыты от нас и особенности реализации деструкторов. При этом нет какого-либо определённого способа эффективной проверки результатов работы деструктора: в некоторых реализациях языка можно обратиться к функциям-членам объекта даже после разрушения этого объекта. По аналогии с конструкторами копирования и преобразования в C++ можно использовать функциональную форму операторов определения переменных основных типов. Синтаксис этих операторов напоминает опе__


раторы, содержащие выражения, вычисление которых "вызов" конструкторов копирования и преобразования:

обеспечивает

ComplexType CDwl(125); ComplexType CDw2(CDwl); int iVall(25); // Соответствует int iVall = 25; int iVal2(iVall); // Соответствует int iVal2 = iVall; Конечно же, это не имеет никакого отношения к классам. Но вместе с тем, здесь мы можем наблюдать замечательную особенность языка C++: синтаксис выражений производных типов по возможности не должен отличаться от синтаксиса выражений основных типов. Синтаксис объявлений и инициализации объектов производных типов влияет на синтаксис объявлений и инициализации объектов основных типов. Последнее, что нам осталось сделать - это выяснить причины, по которым в C++ так различаются объявления, определения и "вызовы" конструкторов и деструкторов и обычных функций-членов класса. Отсутствие спецификации возвращаемого значения и запрещение операции взятия адреса для конструктора и деструктора также имеют свои причины. Если бы в их объявлениях присутствовала спецификация возвращаемого значения (неважно какого) и было бы разрешено применение операции взятия адреса, то в программе можно было бы определять указатели на конструкторы и деструкторы. При этом конструкторы и деструкторы оказались бы похожими обычные функции. Как известно, указатель на функцию характеризуется типом возвращаемого значения и списком параметров функции. Очевидно, что имя функции в этом случае не играет никакой роли. Но как раз имя конструктора и деструктора и позволяет транслятору различать функции, конструкторы и деструкторы. При использовании указателей для вызова функций, деструкторов и конструкторов, транслятор в ряде случаев просто не сможет определить, что, собственно, хочет сделать программист: вызвать функцию, создать новый объект, либо разрушить старый. Ограничения, накладываемые на правила объявления и использования конструкторов и деструкторов, устраняют недоразумения, которые могут возникнуть при вызове функций, обращении к конструкторам и деструкторам.

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


Статические члены класса не связаны ни с одним объектом класса. Они подобны глобальным переменным, но доступны только для членов класса. Их объявления не влияют на объявления членов других классов, их имена не конфликтуют с именами глобальных переменных и функций. Обычные данные-члены являются "собственностью" объекта и определяются в момент создания объекта. Статические данные-члены нуждаются в специальном определении, которое осуществляется вне класса, с использованием квалифицированного имени, состоящего из имени класса, операции разрешения области видимости и собственного имени данного-члена. При определении статические данные-члены дополнительно могут быть проинициализированы. Статические функции не имеют указатель this. Поэтому статическая функция при обращении к нестатическим членам класса должна использовать явным образом объявляемые параметры-ссылки или параметрыуказатели на соответствующие объекты в сочетании с операциями прямого . и косвенного -> выбора. Обращаться к статическим членам класса можно посредством квалифицированного имени с использованием операции доступа к компонентам класса ::, либо обычным способом, с использованием операций выбора . и ->. В последнем случае, значение выражения слева от операции выбора не вычисляется. Вызвать статическую функцию или изменить значение статического данного можно даже через указатель на несуществующий объект, на указатель со значением NULL, по имени несуществующего объекта. В этом случае имя или указатель всего лишь позволяют транслятору определить принадлежность статического члена конкретному классу. Рассмотрим небольшой пример, содержащий объявление, определение и инициализацию статических данных-членов. class MyClass // Объявление класса. < public: /* Объявление нестатического данного-члена класса. */ int NoStatValue; /* Объявление статического данного-члена класса. */ static int StatValue; /* Объявление статического массива-члена класса. */ static int StatArray[2]; /* Объявление двумерного статического массива (массива массивов!). */ static float StatFloatArray[][7]; /•Объявление константного статического массива-члена класса.*/ static const int constStatAxray[2]; /* Объявление статической функции. */

220


static void stFun() ; /* Объявление статической функции с параметрами. */ static void stFunPar(MyClass sxPar);

/* Определение и инициализация статического данного-члена класса. */ int MyClass::StatValue = 100; /* Определение и частичная инициализация статического массива массивов. */ float MyClass::StatFloatArray[][7] = {{1.1, 1,2,),{3.0, 3.14,}}; /* Определение статического массива-члена класса. */ int MyClass::StatArray[2]; /* Определение и инициализация константного статического массива. */ const int MyClass::constStatArray[2] = {1,2}; /* Определение статической функции. */ void MyClass::stFun() { cout « "This is stFunO!" « endl; /* Определение статической функции с параметрами. */ static void stFunPar(MyClass fixPar) < cout « "This is stFunPar()!" « xPar.NoStatValue «

endl;

MyClass *pClass = NULL; MyClass MyClassObj; /* Варианты вызова статической функции. */ MyClass::stFun(); pClass->stFun(); MyClassObj.stFunPar(MyClassObj); /* Обращение к статической функции "от имени" объекта. */

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


нарная операция, обеспечивающая доступ к объектам - членам класса, структуры или объединения. Эта операция уточняет местоположение конкретного члена класса. Её результатом, как в ранее приведённом примере, является определённый член данного класса. В отличие от унарной операции ::, эта операция допускает выражения вида А::В::С::х, которые называют составной квалификацией (составная квалификация имени х). Но возможности этой операции, как и унарной операции доступа ограничены. Составная квалификация применима в очень ограниченном контексте так называемых вложенных объявлений. В данном конкретном случае, для того, чтобы составная квалификация имени х имела бы смысл, необходимо, чтобы А было именем класса, содержащего вложенное объявление класса В. Класс В должен содержать вложенное объявление класса С, а уже в нём должно располагаться объявление данного-члена х. Объявление статического члена класса производного типа отличается от объявления обычного члена класса тем, что для него достаточно предварительного неполного объявления этого класса: class Cl; class C2 < static Cl valCl; );

Правда, для определения этого члена класса неполного объявления всё же недостаточно: c l a s s Cl

class C2 < static Cl valCl; Cl C2::valCl; /* Статический член класса С2 типа Cl определён. */

При объявлении статического члена класса транслятору не нужна полная информация о структуре класса, определяющего тип этого члена. Поэтому при объявлении класса можно объявить статический членпредставитель этого же самого класса. Как известно, для нестатических членов подобное объявление невозможно. При разборе структуры класса это объявление не имеет для транслятора никакого значения. А к моменту определения данного статического члена общая структура класса уже будет известна: class C2 { static C2 valC2; /*Нестатический член класса таким образом объявить невозможно.*/

"222


С2 С2::valC2;

Наследование Наследование — один из основополагающих принципов объектноориентированного программирования. Под наследованием понимается возможность объявления новых типов на основе ранее объявленных. Как известно, в C++ существует фиксированное множество элементарных (основных) типов. Это абсолютно независимые типы и объявление одного элементарного типа на основе другого в принципе невозможно. Спецификации объявления unsigned int или long double нельзя рассматривать как модификации элементарных типов int и double. Это полноправные элементарные типы данных со своим собственным набором свойств. В C++ также невозможно определить одну функцию на основе другой ранее определённой (правда, в C++ существует понятие шаблона функции, и мы обязательно обратимся к этому вопросу). И вот, наконец, для класса, в C++ реализуется возможность наследования. Прежде всего, следует различать наследование и встраивание. В классе можно объявлять как данные-члены основных типов, так и данныечлены ранее объявленных производных типов. Встраивание как раз и предполагает возможность объявления в классе данных-членов класса на основе ранее объявленных классов. В случае же наследования новый класс в буквальном смысле создаётся на основе ранее объявленного класса-предка, НАСЛЕДУЕТ, а возможно и модифицирует его данные и функции. Класс-наследник может служить основой (базовым классом) для новых классов. Классы-потомки наследуют данные и функции своих классов-предков и добавляют собственные компоненты. В C++ количество непосредственных предков класса не ограничено. Класс может быть порождён от одного или более классов. В последнем случае говорят о множественном наследовании. Наследование в C++ реализовано таким образом, что наследуемые компоненты не перемещаются в производный класс, а остаются в базовом классе. Производный класс может переопределять и доопределять функции-члены базовых классов. Механизмы наследования в C++ действуют в соответствии со строгими формальными правилами, которые позволяют транслятору однозначно различать базовые и производные компоненты классов, а также ограничивают множество вариантов наследования.

Наследование. Предки и потомки Наследование при объявлении нового класса задаётся необязательным элементом заголовка класса, который называется спецификацией базы и описывается следующим множеством форм Бэкуса-Наура: 223"


СпецификацияБазы ::= : СписокБаз СписокБаз ::= ^СписокБаз,] ОписательБазы ОписательБазы ПолноеИмяКласса : := fvirtual./ /СпецификаторДоступа] ПолноеИмяКласса : := /'СпецификаторДоступа7 /Virtual./ ПолноеИмяКласса

Нам ещё предстоит выяснить назначение элементов описателя базы, но уже очевидно, что спецификация базы представляет собой список имён классов (имён базовых классов). Поскольку производный класс наследует данные и функции базового класса, базовые классы обязательно должны быть объявлены до объявления производного класса. Для наглядного представления структуры производных классов обычно применяются так называемые направленные ациклические графы, узлы которых представляют классы, а дуги - отношения наследования. Рассмотрим пример, в котором задаются отношения наследования между тремя классами (классы А, В, С). При этом С наследует свойства класса В, который, в свою очередь, является наследником класса А. В этом примере все члены классов объявляются со спецификатором public, к которому мы пока всё ещё относимся как к некоторому волшебному заклинанию. В этих классах мы объявим (просто обозначим) самые простые варианты конструкторов и деструкторов. В настоящий момент нам важно лишь их существование в классе. #include

<iostream.h>

class A { public: A(){}; int int

xO; f 0 ()

class В public:

int int int int int

{return 1 ; } ;

public A {

xl; x2; xx; fl () {return 100;}; f2 () {return 200;};

class С public: 1224

public В {


-DO; ~C()U; int int int int int

xl; x2; x3; fl () {return 1000;} ; f 3 () {return 3000;} ;

void main () {С MyObj;} Перед нами пример простого наследования. Здесь производный класс при объявлении наследует свойства лишь одного базового класса. В качестве базового класса используются лишь полностью объявленные классы. Неполного предварительного объявления здесь недостаточно. Вот как выглядит направленный ациклический граф ранее приведённого в качестве примера класса-наследника С:

Структуру класса-наследника можно также представить в виде таблицы (или схемы класса), отображающей общее устройство класса:

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


Дополним нашу схему, включив в неё объявления всех членов классов, включая, конструкторы и деструкторы. В результате мы получаем полную схему класса-наследника со всеми его компонентами, вместе с его непосредственными, а также косвенными базовыми классами. А А(); ~А(); int xO; int ГО (); В В(); ~В(); int x1; int x2; int xx; int МО; int f2(); С С(); ~С(); int x1; int x2; int хЗ; intf1();

int СО; Это схема класса, а не объекта. Образно говоря, наша схема подобна схеме многоэтажного бункера, разделённого (в данном конкретном случае) на три уровня. На схеме эти уровни разделяются двойными линиями. Класс С занимает самый нижний уровень. Именно этот класс имеет неограниченные (по крайней мере, в нашей версии объявления производного класса) возможности и полномочия доступа к элементам базовых классов. Именно с нижнего уровня можно изменять все (опять же, в нашей версии объявления класса) значения данных-членов класса и вызывать все (или почти все) функции-члены класса. Объект-представитель класса С является единым блоком объектов и включает собственные данные-члены класса С, а также данные-члены классов В и А. Как известно, функции-члены классов, конструкторы и деструкторы не включаются в состав объекта и располагаются в памяти отдельно от объектов. Так что схему объекта-представителя класса можно представить, буквально удалив из схемы класса функции-члены, конструкторы и деструкторы. 226


Следует также иметь в виду, что на схеме класса располагаются лишь объявления данных-членов, тогда как схема объекта содержит обозначения определённых областей памяти, представляющих данные-члены конкретного объекта. Итак, выполнение оператора определения с МуОЬj; приводит к появлению в памяти объекта под именем MyObj. Рассмотрим схему этого объекта. Её отличие от схемы класса очевидно. Здесь мы будем использовать уже известный нам метасимвол ::= (состоит из). На схеме объекта информация о типе данного-члена будет заключаться в круглые скобки. MyObj:^= (int)xO (int)x1 (int)x2 (int)xx (int)x1 (int)x2 (int)x3 Перед нами объект сложной структуры, в буквальном смысле собранный на основе нескольких классов. В его создании принимали участие несколько конструкторов. Порядок их "вызова" строго регламентирован. Вначале вызываются конструкторы базовых классов. Следом вызываются конструкторы производных классов. Благодаря реализации принципа наследования, объект представляет собой цельное сооружение. Из объекта можно вызвать функции-члены базовых объектов. Эти функции наследуются производным классом от своих прямых и косвенных базовых классов. Непосредственно из объекта возможен доступ ко всем данным-членам. Данные-члены базовых классов также наследуются производными классами. Если переопределить деструкторы базовых классов и классовнаследников таким образом, чтобы они сообщали о начале своего выполнения, то за обращением к деструктору класса-наследника С, производимому непосредственно из объекта MyObj: МуОЬ j . ~С() ; последует серия сообщений о выполнении деструкторов базовых классов. Разрушение производного объекта сопровождается разрушением его ба_ _


зовых компонентов. Причём порядок "вызова" деструкторов противоположен порядку "вызова" конструкторов. А вот вызвать деструктор базового класса из объекта производного класса невозможно: MyObj.~B(); // Так нельзя. Это ошибка! Частичное разрушение объекта в C++ не допускается. БАЗОВЫЕ ДЕСТРУКТОРЫ НЕ НАСЛЕДУЮТСЯ. Такова одна из аксиом принципа наследования. Если бы можно было вызывать конструктор непосредственно из объекта, аналогичное утверждение о наследовании можно было бы сделать и по поводу конструкторов. Однако утверждение о том, что базовый конструктор не наследуется так же корректно, как и утверждение о том, что стиральная машина не выполняет фигуры высшего пилотажа. Стиральная машина в принципе не летает. НИ ОДИН КОНСТРУКТОР (ДАЖЕ КОНСТРУКТОР КЛАССАНАСЛЕДНИКА) НЕ ВЫЗЫВАЕТСЯ ИЗ ОБЪЕКТА. К моменту начала разбора структуры производного класса, транслятору становятся известны основные характеристики базовых классов. Базовые классы включаются в состав производных классов в качестве составных элементов. Это означает, что в классе-наследнике (в его функциях) можно обращаться к данным-членам и вызывать функции-члены базовых классов. Можно, если только этому ничего не мешает (о том, что может этому помешать - немного позже). Как раз в нашем случае в этом смысле всё в порядке, и мы приступаем к модификации исходного кода нашей программы. Прежде всего, изменим код функции с именем f t , объявленной в классе С. Мы оставим в классе лишь её объявление, а саму функцию определим вне класса, воспользовавшись при этом её квалифицированным именем. Проблемы, связанные с одноименными членами класса решаются с помощью операции разрешения области видимости. Впрочем, нам это давно известно: int С : :fl() < A::fO<); /*Вызов функции-члена класса А.*/ fO() ; /* Для вызова этой функции можно не использовать специфицированного имени. Функция под именем £0 одна на все классы. И транслятор безошибочно определяет её принадлежность. */ А::х0 = 1; В: : хО = 2 ; С::х0 = 3; хО = 4;

228


/* К моменту разбора этой функции-члена, транслятору известна структура всех составных классов. Переменная хО (как и функция fO) обладает уникальным именем и является общим достоянием базовых классов и классов-наследников. При обращении к ней может быть использовано как её собственное имя, так и имя с любой квалификацией . Аналогичным образом может быть также вызвана и функция f0(). */ В: :fO() ; С: :fO() ; /* Изменение значений данных-членов. */ //A: :xl = 1; /* Ошибка! Переменная xl в классе А не объявлялась.*/ В::xl = 2; С::xl = 3; xl = 4; /* Переменная xl объявляется в двух классах. Транслятор определяет принадлежность данных-членов по квалифициро- ванным именам. В последнем операторе присвоения транслятор считает переменную xl собственностью класса С, поскольку этот оператор располагается "на территории" этого класса. Бели бы класс С не содержал объявления переменной xl, последние три оператора были бы соотнесены с классом В. */ //А::хх = 1; /* Ошибка! Переменная хх в классе А не объявлялась.*/ В::хх - 2; С::хх = 3; хх = 4; /* Аналогичным образом обстоят дела с переменной хх, объявленной в классе В. Хотя хх не объявлялась в классе С, транслятор рассматривает эту переменную как элемент этого класса и не возражает против квалифицированного имени С::хх. В последнем операторе транслятор рассматривает переменную хх как член класса В. */ return 150; Теперь переопределим функцию-член класса В. При её разборе (даже если определение этой функции располагается после объявления класса С), транслятор воспринимает лишь имена базовых классов. В это время транслятор забывает о существовании класса С. А потому упоминание этого имени воспринимается им как ошибка. int В ::f1() A::fO(); А::х0 = 1; В::х0 = 2; //С::х0 = 3; /* Ошибка. */ хО = 4; B::fO(); /* ошибка. */ /* Изменение значений данных-членов. */

229"


//A::xl - 1; /* Ошибка. Переменная xl в классе А не объявлялась.*/ В::xl = 2; //С::х1 • 3; /* Ошибка. */ xl = 4; //А::хх = 1; /* Ошибка! Переменная хх в классе А не объявлялась.*/ В::хх = 2; //С::хх = 3; /* Ошибка. */ хх = 4; return 100; }

Рассмотрим теперь, каким образом транслятор соотносит члены класса непосредственно в объекте. Для этого переопределим функцию main(): void main () { С MyObj; МуОЬ j.хО = 0; МуОЬj.В::хО = 1 ; МуОЬj.С::хО = 2; MyObj.fOO ; MyObj.A: :f0() ; MyObj.C: :f0() ; /* Поиск "снизу-вверх" является для транслятора обычным делом. Транслятор способен отыскать нужные функции и данные даже у косвенного базового класса. Главное, чтобы они были там объявлены. И при было бы возможным однозначное соотнесение класса и его члена. */ MyObj.xl = 777; MyObj.В::xl • 999; cout « MyObj.xl « "-" « MyObj.B::xl; /* Процесс соотнесения осуществляется в направлении от потомков к предкам. Не специфицированный член класса xl считается членом "ближайшего" класса, о чём и свидетельствует последняя тройка операторов. */ MyObj . В: : f 2 () ; MyObj.C: :f2() ; /* И опять успешное соотнесение благодаря поиску "снизу-вверх". Недостающие элементы в классе-наследнике можно поискать по базовым классам. Важно, чтобы они там были. */ // MyObj.A: :fl() ; // MyObj.A::f2(); // MyObj.A: :f3() ; // MyObj.B: :f3() ; /* А вот "сверху вниз" транслятор смотреть не может. Предки не о т вечают за потомков. */

__


Таким образом, корректное обращение к членам класса в программе обеспечивается операцией разрешения области видимости. Квалифицированное имя задаёт область действия имени (класс), в котором начинается (!) поиск данного члена класса. Принципы поиска понятны из ранее приведённого примера.

Наследование и инициализация Ранее рассмотренные механизмы сЬгИнициализации позволяют управлять действиями по построению базовых элементов объектапредставителя класса-наследника. Непосредственно в момент создания производного объекта могут быть вызваны соответствующие функциичлены и конструкторы непосредственных (!) базовых классов. Переопределим ранее объявленные классы А, В, С, добавив в объявления новые варианты конструкторов и функций-членов: # i n d u d e <iostream.h> class A { public: А(){>; A(int key) {cout « " A A A " « // Новый конструктор. ~A(){); int xO; int f0 () {return 1;};

key « endl;};

class В : public A { public: B<)<}; B(int key):A(key) {cout « " л в л " « key « endl;}; /* Конструктор с инициализатором обеспечивает вызов алтьернативного варианта конструктора базового класса. */ ~В()П; int xl; int х2; int хх; | int fl () {return 100;}; int f2 () {return 200;}; int fX (int x){cout « "B::fX... " « x « endl; return x;} /* Новая функция, которая будет принимать участие в инициализации данного-члена класса С. */

class С : public В { public:

COO;

C(int key):B(key), хЗ(В::fX(key)) {cout « endl;}

"С..." «

key

« __


/* Альтернативный конструктор. При создании объекта с помощью данного конструктора в обязательном порядке будут участвовать альтернативные конструкторы базовых классов. Кроме того, для инициализации данного-члена хЗ будет привлечена нестатическая функция-член базового класса B::fX(key). Базовые фрагменты объекта создаются раньше его собственной части, поэтому вызов нестатической функциичлена непосредственного предка не вызывает никаких возражений. */

int int int int int

xl; x2; x3; fl О {return 1000;}; f3 () {return 3000;};

void main () {C MyObj(100);}

Следует также иметь в виду, что в C++ не допускается инициализация непрямых баз. В нашем случае мы не можем влиять на выбор конструктора для класса А непосредственно при создании объектапредставителя класса С). В C++ также не допускается инициализация унаследованных членов. Так, в нашем случае, попытка непосредственной инициализации данных-членов базовых классов А и В при создании объекта-представителя класса С будет восприниматься как ошибка. Квалифицированные имена в инициализаторах (B::fX(key)) особенно актуальны в случае множественного наследования.

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

232


В условиях сложной структуры классов, даже самое подробное документирование класса не может защитить его от ошибок программирования, которые могут возникнуть в результате непреднамеренного изменения значений данных-членов, предназначенных, например, для сохранения промежуточных результатов вычислений. Запрет на вызов функции или на изменение значения данного-члена окажется пустой тратой слов и бумаги, если он не будет подкреплён собственными средствами языка. Транслятор должен знать, что, когда, где и кому можно изменять и вызывать в классе. В этом и заключается принцип инкапсуляции, обеспечивающий возможность сокрытия данных и функций-членов в классе. Программисту, в полной мере использующему принцип инкапсуляции, можно не вникать во все детали сложного вычислительного процесса. Для него базовый класс представляется в виде "чёрного ящика" или капсулы со строго определёнными входами и выходами — интерфейсом класса. Инкапсуляция в C++ обеспечивается системой спецификаторов доступа, которые играют роль ограничителей произвольного доступа к данным и функциям — членам класса. Таких спецификаторов в C++ три. Мы уже встречались с одним из них. Это спецификаторы private, protected и public. Ограничение доступа (или реализация принципа инкапсуляции) является средством повышения надёжности программного обеспечения. Члены класса с ограниченным доступом в меньшей степени подвержены случайным изменениям, вызванным ошибками при разработке программного обеспечения. В объявлении класса спецификатор доступа отделяется двоеточием от списка членов класса, открывая так называемую секцию объявлений членов класса. Очередная секция объявлений начинается непосредственно за спецификатором доступа и включает все объявления членов класса вплоть до появления очередного спецификатора доступа или до конца объявления класса. Распределение списка членов класса по секциям доступа вовсе не означает, что объявление членов класса должно непременно начинаться со спецификатора доступа. В C++ по умолчанию принято, что члены класса, объявленные в классе без спецификатора доступа, образуют секцию, аналогичную секции со спецификатором private. Члены структуры, объявленные без спецификатора доступа, образуют секцию, аналогичную секции в объявлении класса со спецификатором public. struct MyStruct { int si; int fl() {/*...*/)

233


Характеристики уровней доступа Рассмотрим характеристики уровней доступа, задаваемые спецификаторами доступа. • Спецификатор private информирует транслятор о том, что следующие за ним члены класса доступны только из функций-членов этого класса. Таким образом, исключается возможность случайной модификации данных-членов класса и вызова функций-членов любыми не входящими в класс функциями. Члены класса, объявленные с этим спецификатором, называются приватными членами класса. • Спецификатор protected позволяет модифицировать данныечлены класса и вызывать функции-члены класса любым функциям-членам класса и функциям-членам классов-наследников данного класса. Члены класса, объявленные со спецификатором protected, называются защищенными членами класса. • Спецификатор public открывает доступ к членам класса для всех функций программы. Такие члены называются общедоступными. Таким образом, члены класса, объявленного с ключевым словом class, по умолчанию являются приватными членами. Члены класса, объявленного с ключевым словом struct, по умолчанию являются общедоступными. Ограничение доступа распространяется на все члены класса, которые следуют за спецификатором доступа до следующего спецификатора доступа или до конца определения класса. При объявлении класса-наследника спецификатор доступа может быть включён в описатель базы. При объявлении уже известного нам класса С спецификатор public присутствовал в описателях баз классов В и С. Очевидно, что и в этом случае существуют строгие правила, регламентирующие доступ к наследуемым от базового класса членам производного класса. Рассмотрим эти правила, имея в виду, что базовый класс объявляется с ключевым словом class. • Если в описателе базы используется спецификатор public, все общедоступные члены базового класса становятся общедоступными членами класса-наследника, все защищенные члены базового класса становятся защищенными членами класса-наследника. На приватные члены базового класса спецификатор доступа в описателе базы никакого влияния не оказывает. Эти члены базового класса остаются приватными, а значит, недоступными за пределами класса. • Если в описателе базы используется спецификатор protected, все общедоступные и защищенные члены базового класса становятся защищенными членами класса-наследника, приватные члены базового класса так и остаются приватными, то есть недоступными за пределами базового класса. _ _


• Если в описателе базы используется спецификатор private, общедоступные и защищенные члены базового класса становятся приватными членами класса-наследника. • Если в описателе базы не используется никаких спецификаторов, то доступ к членам базового класса определяется ключевым словом в объявлении базового класса. Если базовый класс объявляется с ключевым словом class, то в описателе базы подразумевается спецификатор private, если базовый класс объявляется с ключевым словом struct, то в описателе базы подразумевается спецификатор public. Правила управления доступом жёстко регламентированы и мы не будем описывать различные варианты сочетаний характеристик доступа. Навыки построения "чёрных ящиков" из классов-наследников и их базовых фрагментов формируются в результате кропотливой и тщательной работы над проектом класса и многочисленных экспериментов. Понаблюдать за реакцией транслятора на различные сочетания спецификаторов доступа - занятие занимательное и поучительное. Тем более что полигон из трёх классов уже построен. Очевидно, что механизмы управления доступом защищают от случайного, а не от преднамеренного доступа. От преднамеренного доступа в C++ защиты нет и в принципе быть не может. Через указатели C++ обеспечивает доступ к любой области памяти. В заключение - сводка правил, определяющих уровень доступа при различных сочетаниях спецификаторов доступа базового класса и классов-наследников. Спецификация доступа в базовом классе public protected private public protected private public protected private public protected private

Спецификатор в описателе базы нет нет нет public public public protected protected protected private private private

Доступ в классе-наследнике class struct private private недоступны public protected недоступны protected protected недоступны private private недоступны

public public недоступны public protected недоступны protected protected недоступны private private недоступны

235


Друзья класса Три спецификатора доступа обеспечивают в C++ управление доступом. Спецификаторы доступа способны обеспечить защиту функций и данных от непреднамеренного доступа. На этих спецификаторах основывается принцип инкапсуляции. Объекты-представители "инкапсулированных" классов подобны "чёрным" ящикам, скрывающим внутреннее устройство объекта с чётко обозначенными входами и выходами, реализующими интерфейс класса. Система управления доступом к членам класса, основанная на спецификаторах доступа реализует защиту по принципу "допускать ВСЕХ (члены класса, объявленные в секции public) или не допускать НИКОГО (члены класса, объявленные в секциях protected и private)". В C++ существует возможность организации ещё одной дополнительной системы защиты. Здесь можно также объявлять функции, отдельные функции-члены классов и даже классы (в этом случае речь идёт о полном множестве функций-членов класса), которые получают доступ к защищенным и приватным членам класса. Это означает реализацию системы управления доступом по принципу "не допускать НИКОГО, КРОМЕ". Такие функции и классы называют дружественными функциями и классами. Объявление дружественных классов и функций включается в объявление данного класса вместе со спецификатором объявления friend. Здесь нам потребуется всего одна форма Бэкуса-Наура для того, чтобы дополнить синтаксис объявления. СпецификаторОбъявления : : = f r i e n d Дружбу можно рассматривать как дополнение принципа инкапсуляции. Обозначим основные черты новой системы управления доступом — системой дружественных отношений. Друзья класса не являются членами класса. Они объявляются и определяются вне класса, для которого они объявляются друзьями, а на особые отношения между классом и его "друзьями" указывают лишь специальные объявления^) со спецификатором объявления friend. Для друзей класса нет ограничений на доступ к членам класса. Друзьям доступны все компоненты объявляемого класса. В C++ дружба является односторонним отношением. Дружественные классу функции не являются функциями-членами этого класса. Они не могут быть вызваны от имени объекта-представителя класса, содержащего объявления дружественных функций при помощи операций доступа к члену класса. Дружественная функция может быть функцией-членом другого класса. Правда, при этом само определение дружественной функции приходится располагать после объявления класса, другом которого была объявлена данная функция. 236


Дружественная функция не имеет this указателя для работы с классом, содержащим её объявление в качестве дружественной функции. Дружественные отношения не наследуются. Дружественные функции не имеют доступа к членам класса-наследника, чьи базовые классы содержали объявления этих функций. Дети не отвечают за отношения своих родителей. А теперь рассмотрим небольшой пример использования дружественных функций. В программе объявлены два класса, один из которых является другом другого класса и всеобщая дружественная функция. #include <iostream.h> class XXX; /* Неполное объявление класса. Оно необходимо для объявления типа параметра функции-члена для следующего класса. */ class МММ { private: int ml; public: МММ(int val); void PrintVal(cnar *ObjectName, XXXS ClassParam); >; МММ: :MMM(int val) { ml = val; ) /* Определение функции-члена PrintVal располагается после объявления класса XXX. Только тогда транслятор узнаёт о структуре класса, к которому должна получить доступ функция МММ::PrintVal. */ class XXX { friend class YYY; friend void МММ: : PrintVal (char *ObjectName, XXXS ClassParam); friend void PrintVal (XXXS ClassParamX, YYYS ClassParamY) ; /* В классе объявляются три друга данного класса: класс YYY, функция-член класса МММ, простая функция PrintVal. В класс XXX включаются лишь объявления дружественных функций и классов. Все определения располагаются в других местах - там, где им и положено быть - в своих собственных областях видимости. */ private: int xl; public: XXX(int val); >; XXX::XXX(int val) { xl = val ; ) void МММ-.-.PrintVal (char *ObjectName, XXXS ClassParam) { cout « "Значение " « ObjectName « ": " « ClassParam.xl «

endl;

j 237


' Отложенное определение функции-члена МММ: :PrintVal. */ class YYY < friend void PrintVal (XXXS ClassParamX, YYYS ClassParamY) ; private: int yl; public: YYY(int val); void PrintVal(char «ObjectName, XXXS ClassParam); }; YYY: :YYY(int val) { yl = val; > void YYY: : PrintVal (char *ObjectName, XXXS ClassParam) < cout « "Значение " « ObjectName « ": " « ClassParam.xl « )

endl;

void PrintVal (XXXS ClassParamX, YYYS ClassParamY) ; void main() < XXX meml(1); XXX mem2(2); XXX mem3(3) ; // Определены объекты-представители класса XXX.

v

YYY displ(l); YYY disp2(2) ; // Определены объекты-представители класса YYY. МММ special(0) ; // Определен объект-представитель класса МММ. displ. PrintVal ("meml" , meml); di sp2. PrintVal ("mem2 " , mem2) ; disp2. PrintVal ("mem3" , mem3) ; // Функция-член класса YYY имеет доступ к частным членам класса XXX. special. PrintVal ("\n mem2 from special spy:", mem2) ; // Функция-член класса МММ имеет доступ к частным членам класса XXX. PrintVal (meml, disp2) ; PrintVal (mem2, displ); // Функция имеет доступ к частным членам классов XXX и YYY.

void PrintVal(XXXS ClassParamX, YYYS ClassParamY) ( cout « endl; cout « "???.xl = " « ClassParamX.xl « endl; cout « "???.yl = " « ClassParamY.yl « endl; )

В этом примере все функции имеют одинаковые имена. Конфликта имён не происходит, поскольку эти имена располагаются в разных областях действия. 238


Указатели на объекты Рассмотрим простой пример: #include <iostream.h> class A { >; class AB: public A { }; class AC: public A { }; void main () { A *pObj; A MyA; pObj = ШуА; cout « "OK A" «

endl;

AB MyAB; AC MyAC; pObj = SMyAB; cout « "OK AB" «

endl;

pObj = SMyAC; cout « "OK AC" «

endl;

Это очень простой пример. Пустые классы, простое наследование... В объявлении этих классов принципиальны лишь спецификаторы доступа в описании баз классов-наследников. Члены базового класса (его будущие члены) должны быть доступны в классе-наследнике. Первый оператор функции main() - объявление указателя на объект класса А. Затем следует определение объекта-представителя класса А, следом - настройка указателя на этот объект. Естественно, при этом используется операция взятия адреса. Всё это давно известно и очень просто. Следующие две строки являются определениями пары объектов, которые являются представителями двух разных классов-наследников...

239


За объявлениями объектов в программе располагаются строки, которые свидетельствуют о том, указатель на базовый класс можно настроить на объект класса-наследника. Для настройки указателя на объект класса-наследника не требуется никаких дополнительных преобразований. Здесь принципиально только то, что между классами должно существовать отношение наследования. Так проявляется очень важное свойство объектно-ориентированного программирования: УКАЗАТЕЛЬ НА БАЗОВЫЙ КЛАСС МОЖЕТ ССЫЛАТЬСЯ НА ОБЪЕКТЫ-ПРЕДСТАВИТЕЛИ КЛАССОВ-НАСЛЕДНИКОВ. Это обстоятельство является следствием еще одного правила неявного преобразования типов, которое гласит, что указатель (ссылка) на произвольный класс может быть неявно преобразован в указатель (ссылку) на базовый класс. Рассмотрим схемы объектов МуА, МуАВ, МуАС: МуА::=

А МуАВ::=

А АВ МуАС::•

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

АС Ниже пунктирной линии - пустота. Для того чтобы убедиться в этом, мы усложним структуру класса А, определив в нём функцию Fun1. Конечно же, эта функция ничего не будет делать. Но у неё будет спецификация возвращаемого значения и непустой список параметров. Нам от неё большего и не требуется. Лишь бы сообщала о собственном вызове... class A { public:


int Funl(int);

int A::Funl(int key) { cout « " Funl( " « key « " ) from A " « endl; return 0; } Аналогичной модификации подвергнем классы-наследники АВ и АС (здесь предполагаются вызовы функций-членов непосредственно из функции main(), а потому надо помнить о спецификаторе public), а затем продолжим опыты. class АВ: public A < public: int Funl(int key); int AB::Funl(int key) { cout « " Funl( " « key « return 0;

" ) from AB " «

endl;

class AC: public A { public: int Funl(int key); int Fun2(int key); // В этом классе мы объявим вторую функцию. int AC::Funl(int key) < cout « « Funl( " « key « return 0;

" ) from AC " «

endl;

int AC::Fun2(int key) { cout « " Fun2( " « key « " ) from AC " « endl; return 0; } Теперь мы займёмся функцией main(). Первая пара операторов последовательно из объекта запускает функцию-член класса-наследника, а затем - подобную функцию базового класса. С этой целью используется квалифицированное имя функции-члена. __.


MyAC.Fun2(2); MyAC.Funl(2); MyAC.A::Funl(2)

//Вызвана АС: : Fun2 ()... //Вызвана AC: : Funl ()... //Вызвана A::Funl()...

Следующие строки посвящены попытке вызова функций-членов по указателю на объект базового типа. Предполагается, что в данный момент он настроен на объект МуАС. p 0 b j - > F u n l ( 2 ) ; //Вызвана A : : F u n l ( ) . . . И это всё, что можно способен указатель на объект базового типа, если его настроить на объект-представитель класса-наследника. Ничего нового. Тип указателя на объект - базовый класс. В базовом классе существует единственная функция-член, она известна транслятору, а про структуру класса-наследника в базовом классе никто ничего не знает. Так что следующие операторы представляют пример того, что не следует делать с указателем на объекты базового класса, даже настроенного на объект класса-наследника. //pObj->Fun2(2); //pObj->AC::Funl(2) ; То ли дело указатель на объект-представитель класса-наследника! И опять здесь нет ничего нового и неожиданного. С "нижнего этажа бункера" видны все "этажи"! AC* pObjAC = SMyAC; p0bjAC->Funl(2) ; pObjAC->Fun2(2) ; pObjAC->AC::Funl(2) ; pObjAC->Funl(2) ; pObjAC->A::Funl(2); И, разумеется, указатель на объект класса-наследника не настраивается на объект базового. //pObjAC = &МуА; Основной итог этого раздела заключается в следующем: указатель на объект базового класса можно настроить на объект класса-наследника. Через этот указатель можно "увидеть" лишь фрагмент объекта классанаследника - его "базовую" часть — то, что объект получает в наследство от своих предков. Решение о том, какая функция должна быть вызвана, принимается транслятором. В момент выполнения программы всё уже давно решено. Какая функция будет вызвана из объекта-представителя 242


.jiacca-наследника - зависит от типа указателя, настроенного на данный объект. В этом случае мы наблюдаем пример статического связывания.

Виртуальные функции Очередная модификация базового класса приводит к неожиданным последствиям. Эта модификация состоит в изменении спецификатора функции-члена базового класса. Мы (впервые!) используем спецификатор virtual в объявлении функции. Функции, объявленные со спецификатором virtual, называются виртуальными функциями. Введение виртуальных функций в объявление базового класса (всего лишь один спецификатор) имеет столь значительные последствия для методологии объектноориентированного программирования, что мы лишний раз приведём модифицированное объявление класса А: class A { public: virtual int Funl(int); }; Один дополнительный спецификатор в объявлении функции и больше никаких (пока никаких) изменений в объявлениях классовнаследников. Как всегда, очень простая функция main(). В ней мы определяем указатель на объект базового класса, настраиваем его на объектпредставитель класса-наследника, после чего вызываем по указателю функцию Fun1(): void main () { A *pObj; А My A; АВ МуАВ; pObj = Ш у А ; pObj->Funl(l); АС МуАС; pObj = SMyAC; pObj->Funl(l);

Если бы не спецификатор virtual, результат выполнения выражения вызова pObj->Funl(1); _ _


был бы очевиден: как известно, выбор функции определяется типом указателя. Однако спецификатор virtual меняет всё дело. Теперь выбор функции определяется типом объекта, на который настраивается указатель базового класса. Если в классе-наследнике объявляется нестатическая функция, у которой имя, тип возвращаемого значения и список параметров совпадают с аналогичными характеристиками виртуальной функции базового класса, то в результате выполнения выражения вызова вызывается функция-член класса-наследника. Надо заметить, что возможность вызова функции-члена классанаследника по указателю на базовый класс не означает, что появилась возможность наблюдения за объектом "сверху вниз" из указателя на объект базового класса. Невиртуальные функции-члены и данные "сверху" попрежнему недоступны. И в этом можно очень легко убедиться. Для этого достаточно попробовать сделать то, что мы уже однажды проделали вызвать неизвестную в базовом классе функцию-член класса-наследника: //pObj->Fun2(2) ; //pObj->AC::Funl(2); Результат отрицательный. Указатель, как и раньше, настроен лишь на базовый фрагмент объекта класса-наследника. И всё же появляется возможность вызова некоторых функций класса-наследника. Когда-то, в разделах, посвященных описанию конструкторов, нами был рассмотрен перечень регламентных действий, которые выполняются конструктором в ходе преобразования выделенного фрагмента памяти в объект класса. Среди этих мероприятий упоминалась инициализация таблиц виртуальных функций. Наличие этих самых таблиц виртуальных функций можно попытаться обнаружить с помощью операции sizeof. Конечно, здесь всё зависит от конкретной реализации, но, по крайней мере, в версии Borland C++ объектпредставитель класса, содержащего объявления виртуальных функций, занимает больше памяти, нежели объект аналогичного класса, в котором те же самые функции объявлены без спецификатора virtual. cout «

"Размеры объекта: " «

sizeof (MyАС) «

"..." «

endl;

Так что объект-представитель класса-наследника приобретает дополнительный элемент - указатель на таблицу виртуальных функций. Схему такого объекта можно представить следующим образом (указатель на таблицу мы обозначим идентификатором vptr, таблицу виртуальных функций — идентификатором vtbl):

244


МуАС::=

vptr

"Л"' AC

vtbl::=

&AC::Fun1

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

pObj->Funl(l); можно представить следующим образом: (*(рОЬj->vptr[0]))

(рОЬj,1);

Здесь только на первый взгляд всё запутано и непонятно. На самом деле, в этом операторе нет ни одного не известного нам выражения. Здесь буквально сказано следующее: ВЫЗВАТЬ ФУНКЦИЮ, РАСПОЛОЖЕННУЮ ПО НУЛЕВОМУ ИНДЕКСУ ТАБЛИЦЫ ВИРТУАЛЬНЫХ ФУНКЦИЙ vtbl (в этой таблице у нас всего один элемент), АДРЕС НАЧАЛА КОТОРОЙ МОЖНО НАЙТИ ПО УКАЗАТЕЛЮ vptr. УКАЗАТЕЛЬ vptr ДОСТУПЕН ПО УКАЗАТЕЛЮ pObj, НАСТРОЕННОМУ НА ОБЪЕКТ МуАС. ФУНКЦИИ, ПЕРЕДАЁТСЯ ДВА (!) ПАРАМЕТРА, ПЕРВЫЙ ИЗ КОТОРЫХ ЯВЛЯЕТСЯ АДРЕСОМ ОБЪЕКТА МуАС (значение для this указателя!), ВТОРОЙ - ЦЕЛОЧИСЛЕННЫМ ЗНАЧЕНИЕМ, РАВНЫМ 1. Вызов функции-члена базового класса обеспечивается посредством квалифицированного имени. pObj->A: : F u n l ( l ) ; При этом мы отказываемся от услуг таблицы виртуальных функций и сообщаем транслятору о намерении вызвать функцию-член базового 245"


класса. Механизм поддержки виртуальных функций строг и очень жёстко регламентирован. Указатель на таблицу виртуальных функций обязательно включается в самый "верхний" базовый фрагмент объекта классанаследника. В таблицу указателей включаются адреса функций-членов фрагмента самого "нижнего" уровня, содержащего объявления этой функции. Мы в очередной раз модифицируем объявление классов А, АВ и объявляем новый класс ABC. Модификация классов А и АВ сводится к объявлению в них новых функций-членов: class A { public: virtual int Funl(int key); virtual int Fun2(int key);

int A::Fun2(int key) { cout « " Fun2( " « r e t u r n 0;

key «

" ) from A " «

endl;

class AB: public A { public: int Funl(int key); int Fun2(int key);

int AB::Fun2(int key) { cout « " Fun2( " « key « " ) from AB " « return 0; } Класс A B C является наследником класса АВ: class ABC: public AB { public: int Funl(int key); }; int ABC::Funl(int key) ( __

endl;


cout « " Funl{ return 0;

«

key «

" ) from ABC " « endl;

В этот класс входит объявление функции-члена Fun1, которая объявляется в косвенном базовом классе А как виртуальная функция. Кроме того, этот класс наследует от непосредственной базы функцию-член Fun2. Эта функция также объявляется в базовом классе А как виртуальная. Мы объявляем объект-представитель класса ABC: АВС МуАВС; Его схему можно представить следующим образом: МуАВС::=

vptr A AB ABC

i

vtbl::= &AB::Fun2 &ABC::Fun1

<i

J

Таблица виртуальных функций сейчас содержит два элемента. Мы настраиваем указатель на объект базового класса на объект МуАВС, затем вызываем функции-члены: pObj = SMyABC; pObj->Funl(l); pObj->Fun2(2); В этом случае невозможно вызвать функцию-член AB::Fun1(), поскольку её адрес не содержится в списке виртуальных функций, а с верхнего уровня объекта МуАВС, на который настроен указатель pObj, она просто не видна. Таблица виртуальных функций строится конструктором в процессе создания соответствующего объекта. Безусловно, транслятор обеспечивает соответствующее кодирование конструктора. Но транслятор не в состоянии определить содержание таблицы виртуальных функций для конкретного объекта. Это задача решается во время выполнения программы. Пока таблица виртуальных функций не будет построена для конкретного объекта, соответствующая функция-член класса-наследника не сможет быть вызвана. В этом легко убедиться, после очередной модификации объявления классов. 247


Программа невелика, поэтому имеет смысл привести её текст полностью. Не следует обольщаться по поводу операции доступа к компонентам класса ::. •include <iostream.h> class А < public: virtual int Funl(int key) ; ); int A::Funl(int key) ( cout « " Funl( " « return 0; >

key «

" ) from A." « endl;

class AB: public A { public: AB() <Funl<125);); int Fun2(int key); int AB::Fun2(int key)

1

FunKkey * 5) ; cout « * Fun2( " « key « return 0;

" ) from AB." « endl;

class ABC: public AB { public: int Funl(int key); int ABC::Funl(int key) { cout « " Funl( " « key « return 0;

" ) from ABC." « endl;

void main () { ABC MyABC; // Вызывается A::Funl(). MyABC.Funl(l); MyABC.Fun2(l); MyABC.A::Funl(l);

// Вызывается ABC::Funl(). // Вызываются AB::Fun2() и ABC::Funl(). // Вызывается A::Funl().

A *pObj = ШуАВС;

// Определяем и настраиваем указатель.

248


cout «

"=========" «

pObj->Funl(2); //pObj->Fun2(2); // pObj->A::Funl(2); >

endl; // Вызывается ABC::Funl(). // Эта функция через указатель недоступна! // Вызывается A::Funl().

Теперь в момент создания объекта МуАВС АВС МуАВС;

из конструктора класса АВ (а он вызывается раньше конструктора класса АВС), будет вызвана функция A::Fun1(). Эта функция является членом класса А. Объект МуАВС ещё до конца не сформирован, таблица виртуальных функций ещё не заполнена, о существовании функции ABC::Fun1() ещё ничего не известно. После того, как объект МуАВС будет окончательно сформирован, таблица виртуальных функций заполнится, а указатель pObj будет настроен на объект МуАВС, вызов функции A::Fun1() через указатель pObj будет возможен лишь при использовании полного квалифицированного имени этой функции: pObj->Funl(1); // Это вызов функции ABC::Funl()! pObj->A::Funl(l); // Очевидно, что это вызов функции A::Funl()! Заметим, что вызов функции-члена Fun1 непосредственно из объекта МуАВС приводит к аналогичному результату: МуАВС.Funl(1);

// Вызов функции ABC::Funl().

А попытка вызова невиртуальной функции AB::Fun2() через указатель на объект базового класса заканчивается неудачей. В таблице виртуальных функций адреса этой функции нет, а с верхнего уровня объекта "посмотреть вниз" невозможно. //p0bj->Fun2(2); // Так нельзя! Результат выполнения этой програмы демонстрирует специфику использования виртуальных функций. Всего несколько строк... Funl(125) from A. Funl(l) from ABC. Funl(5) from ABC. Fun2(1) from AB. Funl(l) from A. Funl(2) from ABC. Funl(2) from A.

249


Один и тот же указатель в ходе выполнения программы может настраиваться на объекты-представители различных классов-наследников. В результате в буквальном смысле одно и то выражение вызова функциичлена обеспечивает выполнение совершенно разных функций. Впервые мы сталкиваемся с так называемым ПОЗДНИМ, ОТЛОЖЕННЫМ или ДИНАМИЧЕСКИМ СВЯЗЫВАНИЕМ. В дальнейшем мы будем называть виртуальную функцию базового класса замещаемой функцией, а функцию-член класса-наследника замещающей, если эти функции имеют одинаковое имя, спецификацию возвращаемого значения и совпадающие списки параметров. Заметим, что спецификация virtual относится только к функциям. Виртуальных данных-членов не существует. Это означает, что не существует возможности обратиться к данным-членам объекта классанаследника по указателю на объект базового класса, настроенному на объект класса-наследника. С другой стороны, очевидно, что если можно вызвать замещающую функцию, то непосредственно "через" эту функцию открывается доступ ко всем функциям и данным-членам членам классанаследника и далее "снизу-вверх" ко всем неприватным функциям и данным-членам непосредственных и косвенных базовых классов. При этом из функции становятся доступны все неприватные данные и функции базовых классов. И ещё один маленький пример, демонстрирующий изменение поведения объекта-представителя класса-наследника после того, как одна из функций базового класса становится виртуальной. #include <iostream.h> class A ( public: void funA () {xFun();>; /*virtual*/void xFun О {cout «"this is void A: :xFun() ; " « >;

endl;};

class B: public A ( public: void xFun () {cout «"this is void B::xFun () ; "«endl;} ; }; void main() ( В objB; objB.funAO ; >

В начале спецификатор virtual а определении функции A::xFun() закомментирован. Процесс выполнения программы состоит в определении объекта-представителя objB класса-наследника В и вызова для этого объекта функции-члена funA(). Эта функция наследуется из базового класса, она одна и очевидно, что её идентификация не вызывает у транслятора никаких проблем. Эта функция принадлежит базовому классу, а это озна250


чает, что в момент её вызова, управление передаётся "на верхний уровень" объекта objB. На этом же уровне располагается одна из функций с именем xFun(), и именно этой функции передаётся управление в ходе выполнения выражения вызова в теле функции funA(). Мало того, из функции funA() просто невозможно вызвать другую одноименную функцию. В момент разбора структуры класса А транслятор вообще не имеет никакого представления о структуре класса В. Функция xFun() - член класса В оказывается недостижима из функции funA(). Но если раскомментировать спецификатор virtual в определении функции A::xFun(), между двумя одноименными функциями установится отношение замещения, а порождение объекта objB будет сопровождаться созданием таблицы виртуальных функций, в соответствии с которой будет вызываться замещающая функция-член класса В. Теперь для вызова замещаемой функции необходимо использовать её квалифицированное имя: v o i d A : : f u n A () { xFun() ; A: : x F u n ( ) ;

Чистые виртуальные функции и абстрактные классы Виртуальные функции расширяют возможности программирования. Можно объявить базовый класс с замещаемыми функциями, после чего объявляются классы-наследники (производные от данного базового класса), содержащие объявления и определения замещающих функций. Несмотря на то, что между замещаемыми и замещающими функциями может не быть абсолютно ничего общего, всё же существует и действует стандартный механизм вызова замещающих функций через указатель на объект базового класса, настраиваемый на объекты классов-наследников. Известно множество случаев, когда механизм замещения функций позволяет значительно повысить производительность и эффективность процесса программирования. Примером использования механизма замещения является создание графической программы для изображения различных геометрических фигур. Разработку такой программы целесообразно начинать с объявления базового класса, представляющего абстрактную фигуру, а не с разработки классов, отвечающих за изображение конкретных фигур. #define AllShapes 100 class Shape // Образ. { 251


public: ::::: // Множество даяных-членов. virtual void Draw(); // Виртуальная функция рисования.

void Shape::Draw()

i // Ещё надо подумать, какой здесь должен быть код... В этом примере нам абсолютно не важны детали реализации класса Shape. Главное, что на основе этого класса может быть объявлен массив указателей на объекты класса Shape: Shape

*ShapesArray[AllShapes];

и реализован перебор элементов массива указателей: for (int i = 0; i < AllShapes; i++) ShapesArray[i]-> Draw(); У нас ещё нет ни одной конкретной геометрической фигуры, а уже фактически готов механизм рисования изображений. Классы, представляющие конкретные геометрические фигуры, объявляются как производные от базового класса Shape: c l a s s C i r c l e : p u b l i c Shape { public: v o i d Draw() ; void Circle::Draw() // Код, отвечающий за рисование данного круга.

class Rectangle: public Shape

public: void Draw(); } void

Rectangle::Draw()

// Код, отвечающий sa рисование данного __

прямоугольника.


} class Line: public Shape

public: void Draw(); void Line::Draw() { // Код, отвечающий за рисование данной линии. } Мы опускаем детали реализации различных классов геометрических фигур. Например, каждый класс может быть снабжён конструктором с параметрами, задающими в процессе создания объекта его конкретные характеристики (координаты, основные размеры, цвет и многое другое). Массив указателей на объекты-представители класса ShapesArray можно проинициализировать серией обращений к конструкторам производных классов (по понятным причинам мы используем конструкторы умолчания): ShapesArray[0] = new Circle; ShapesArray[1] = new Line(); // Здесь допускаются скобки. ShapesArray[2] = new Rectangle; Новые производные классы геометрических фигур можно объявлять, не меняя основной логики программы, которая была определена задолго до объявления первого класса-наследника. Между прочим, ничто не может помешать объявлению виртуальной функции, как функции, дружественной какому-либо классу. Дружественной функции предоставляются особые права доступа к членам класса. Механизм вызова этих функций для этого класса не принципиален. Вполне возможно, что виртуальная функция базового класса так никогда и не будет вызываться в программе. Основное назначение виртуальной функции - обеспечить её замещение в таблице виртуальных функций адресом замещающей функции класса-наследника. И если замещаемая функция не предназначается для непосредственного вызова, то достаточно ограничиться её объявлением и вообще отказаться от её определения. Для этого в C++ предусмотрена особая конструкция, называемая чистым спецификатором. Мы уже встречались с этим спецификатором. Среди множества БНФ, посвященных синтаксису объявления класса, были и такие формы Бэкуса-Наура: ОписательЧленаКласса : := Описатель ЧистыйСпецификатор ::= = О

/"ЧистыйСпецификатор ]

253


В этом контексте описатель представляет прототип функции, за которым следует чистый спецификатор — операция присвоения и ноль. Прототип виртуальной функции в объявлении класса, завершающийся чистым спецификатором, называется объявлением чистой виртуальной функции. Чистая виртуальная функция не нуждается в дополнительном определении. Чистый спецификатор в объявлении функции-члена уведомляет транслятор о том, что данная виртуальная функция выступает исключительно в качестве замещаемой функции для замещающих функций классов-наследников. Чистая виртуальная функция оказывается функцией без кода, и её имя обычно ассоциируется с нулевым адресом. Чистая виртуальная функция наследуется, оставаясь при этом чистой виртуальной функцией. Следует иметь в виду, что транслятор не контролирует корректность работы с адресами, а потому при работе с чистыми виртуальными функциями следует соблюдать особую осторожность. Впрочем, здесь нет ничего нового: ответственность за большинство ошибок времени выполнения в C++ возлагается на программиста. Вернёмся к ранее рассмотренному примеру с тремя классами А, АВ, ABC. Модифицируем его таким образом, чтобы виртуальная функция-член класса А оказалась бы чисто виртуальной функцией. Для этого достаточно приписать к прототипу функции чистый спецификатор и удалить её определение. После этого трансляция пройдёт успешно, однако во время выполнения этой программы возникает серьёзная ошибка: при создании объекта класса-наследника ABC из конструктора АВ вызывается функция Fun1(). В этот момент таблица виртуальных функций ещё не построена, а потому управление передаётся замещаемой функции базового класса. Передача управления неопределённой функции ведёт к неизбежному аварийному завершению программы. #include <iostream.h> class A { public: virtual int Funl(int key) = 0; class AB: public A public: AB() { //В конструкторе скрытая ошибка! Вызов Funl опасен. Funl(125); // Попытка вызова чистой виртуальной функции. }; int Fun2(int key);

int AB::Fun2(int key) _ _


Funl(key * 5); cout « • Fun2( " « return 0;

key «

• ) from AB." « endl;

class ABC: public AB ( public: int Funl(int key); int ABC::Funl(int key) { cout « " Funl( " « key « return 0;

" ) from ABC." « endl;

void main () { /* В этой функции очень много ошибок времени исполнения, связанных передачей управления чистой виртуальной функции. Все они, кроме одной, о которой мы уже говорили, очевидны.*/ ABC MyABC;

//Неявный вызов из конструктора функции A::Funl().

MyABC.Funl(l); // Вызывается ABC::Funl(). MyABC.Fun2(l); // Вызываются AB::Fun2() и ABC::Funl(). MyABC.A::Funl(1); // Вызывается A::Funl(). He дело! A *pObj = ШуАВС; // Определяем и настраиваем указатель. pObj->Funl(2); // Вызывается ABC::Funl(). pObj->A::Funl(2); // Вызывается A::Funl(). Надо исправить. }

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

255


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

Множественное наследование В C++ класс-наследник может быть порождён из любого числа непосредственных базовых классов. Наличие у класса-наследника более чем одного непосредственного базового класса является следствием множественного наследования. Синтаксически множественное наследование отличается от единичного наследования списком баз, состоящим более чем из одного элемента. class A

class В

class С : public A, public В { >;

При создании объектов-представителей класса-наследника, порядок расположения непосредственных базовых классов в списке баз определяет очерёдность "вызова" конструкторов умолчания. Этот порядок влияет и на очерёдность "вызова" деструкторов при уничтожении этих объектов. Но эти проблемы, также как и алгоритмы выделения памяти для базовых объектов, скорее всего, относятся к вопросам реализации. Вряд ли программист должен акцентировать на этом особое внимание. Более существенным является ограничение, согласно которому одно и то же имя класса не может входить более одного раза в список баз при объявлении класса-наследника. Это означает, что в наборе непосред256


ственных базовых классов, которые участвуют в формировании классанаследника не должно встречаться повторяющихся элементов. Вместе с тем, один и тот же класс может участвовать в формировании нескольких (а может быть и всех) непосредственных базовых классов данного класса-наследника. Так что для непрямых базовых классов, участвующих в формировании класса-наследника не существует никаких ограничений на количество вхождений в объявление класса-наследника: class A < public: int

xO,

хА;

class В : public A { public: int хВ; class С : public A { public: int xO, хС; class D : public B, public С ( public: int xO, xD; ); В этом примере класс А дважды используется при объявлении класса D в качестве непрямого базового класса. Для наглядного представления структуры класса-наследника также используются направленные ациклические графы, схемы классов и объектов. Вот как выглядит граф ранее приведённого в качестве примера класса-наследника D: А

А

А

257


Как и раньше, самый нижний узел направленного ациклического графа, а также нижний уровень схем соответствует классу-наследнику и фрагменту объекта, представляющего класс-наследник. Такой фрагмент объекта мы будем называть производным фрагментом-представителем данного класса. Верхние узлы графа и верхние уровни схем классов и объектов соответствуют базовым классам и фрагментам объектов, представляющих базовые и непосредственные базовые классы. Эти фрагменты объекта мы будем называть базовыми и непосредственными базовыми фрагментамипредставителями класса. А вот как представляется структура класса-наследника в виде неполной схемы класса. Базовые классы располагаются на этой схеме в порядке, который соответствует списку базовых элементов в описании базы класса-наследника. Этот же порядок будет использован при изображении диаграмм объектов. И это несмотря на то обстоятельство, что порядок "вызова" конструкторов базовых классов определяется конкретной реализацией. За порядком "вызова" конструкторов базовых классов всегда можно наблюдать после определения их собственных версий. А В А С D А вот и схема объекта класса-наследника. D MyD; MyD : :<А_ (int)xO; (int)xA;

' В

(int)xB; А (int)xO; (int)xA; (int)xO; D (int)xO; (int)xD; 258


Первое, что бросается в глаза - это множество одноимённых переменных, "разбросанных" по базовым фрагментам объекта. Да и самих базовых фрагментов здесь немало. Очевидно, что образующие объект базовые фрагментыпредставители одного базового класса, по своей структуре неразличимы между собой. Несмотря на свою идентичность, все они обладают индивидуальной характеристикой - положением относительно производного фрагмента объекта. При множественном наследовании актуальной становится проблема неоднозначности, связанная с доступом к членам базовых классов. Доступ к члену базового класса является неоднозначным, если выражение доступа именует более одной функции, объекта (данные-члены класса также являются объектами), типа (об этом позже!) или перечислителя. Например, неоднозначность содержится в следующем операторе: MyD.xA = 1 0 0 ; здесь предпринимается неудачная попытка изменения значения данногочлена базового фрагмента объекта MyD. Выражение доступа MyD.xA именует сразу две переменных хА. Разрешение неоднозначности сводится к построению такого выражения доступа, которое однозначно указывало бы функцию, объект, тип (об этом позже!) или перечислитель. Наша очередная задача сводится к описанию однозначных способов доступа к данным-членам класса, расположенным в разных базовых фрагментах объекта. И здесь мы впервые сталкиваемся с ограниченными возможностями операции доступа. MyD.В::х0 = 100;

Этот оператор обеспечивает изменение значения данного-члена базового фрагмента - представителя класса В. Здесь нет никаких проблем, поскольку непосредственный базовый класс В наследует данныечлены базового класса А. Поскольку в классе В отсутствуют данные-члены с именем хО, транслятор однозначно определяет принадлежность этого элемента. Итак, доступ к данному-члену базового класса А "со стороны" непосредственного базового класса В не представляет особых проблем. MyD.С::хО -

100;

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


MyD.A::xO = 777; некорректен по причине неоднозначности соотнесения класса и его члена, поскольку непонятно, о каком базовом фрагменте-представителе класса А идёт речь. Выражения доступа с составными квалифицированными именами, как например, MyD.C::A::хО в контексте нашей программы также некорректны: составное квалифицированное имя предполагает вложенное объявление класса. Это свойство операции доступа уже обсуждалось ранее, в разделах, непосредственно посвященных операциям. Вложенные объявления будут рассмотрены ниже. Операция :: оставляет в "мёртвой зоне" целые фрагменты объектов. Однако возможность доступа к членам класса, которые оказались вне пределов досягаемости операции доступа всё же существует. Она обеспечивается указателями и операциями явного преобразования типа. Идея состоит в том, чтобы, объявив указатель на объектпредставитель базового класса, попытаться его настроить с помощью операций явного преобразования типа на соответствующий фрагмент объекта-представителя класса-наследника. В результате недосягаемые с помощью операции доступа фрагменты объекта превращаются в безымянные объекты простой конфигурации. Доступ к их членам в этом случае обеспечивается обычными операциями косвенного выбора. Рассмотрим несколько строк, которые демонстрируют такую технику работы с недосягаемыми фрагментами. A* В* С* D* //

pObjA; pObjB; pObjC; pObjD = &MyD; Мы начинаем с объявления соответствующих указателей.

pObjC = (С*)&MyD; pObjA = (A*)pObjC; // Произведена настройка указателей на требуемые фрагменты. pObjA->xO = 999; / / А это уже элементарно! Очевидно, что можно обойтись без поэтапных преобразований и воспользоваться свойством коммутативности операции явного преобразования типа: ((A*)(C*)pObjD)->xO = 5; _


((A*) <B*)pObjD)->xO = 55; // Разным фрагментам - разные значения. Аналогичным образом обстоят дела с функциями-членами базовых классов. Этот раздел мы завершаем небольшой программой, демонстрирующей методы доступа к членам базовых фрагментов объекта классанаследника. По этой программе можно восстановить графы и структуры классов, схему объекта, а затем каким образом и откуда обеспечивается доступ к его компонентам. #inolude <iostream.h> class A < public: int xO; int Funl(int key); int A::Funl(int key) { cout « " Funl( " « cout « " xO == " « return 0;

key « " ) from A " « xO « "..." « endl;

endl;

key « " ) from В " « xO « "..." « endl;

endl;

key « " ) from В " « xO « "..." « endl;

endl;

class B: public A { public: int xO; int Funl(int key); int Fun2(int key) ;

int B::Funl(int key) { cout « " Funl( " « cout « " xO == " « return 0; int B::Fun2(int key) { Funl(key * 5 ) ; cout « " Fun2( " « cout « " xO == " « return 0;

__


class C: public A { public: int xO; int Fun2(int key); int C::Fun2(int key) { A::xO = 25; Funl(key * 5 ) ; cout « " Fun2( " « cout « " xO == " « return 0;

key « " ) from С " « xO « "..." « endl ;

endl;

class D: public B, public С { public: int xO; int Funl(int key); int D::Funl(int key)

I

cout « " Funl( " « cout « " xO == " « return 0; void main () { D MyD; ObjD.xO = 111; A* pObjA; B* pObjB; C* pObjC; D* pObjD = SMyD;

MyD.B::xO = 100; MyD.C::x0 = 333; MyD.Funl (1) ; pObjD->B::Funl(l)

262

key « " ) from D " « xO « "..." « endl;

endl;


рОЬjD->C::Fun2(1) ; pObjA = (A*) (B*) pObjD; ((A*) ((C*) pObjD))->Funl(lll) ((A*) ((B*) pObjD))->Funl(lll) pObjA->Funl(111); pObjC = (C*) SMyD; pObjA = (A*)pObjC; ((A*)(B*)pObjD)->xO = 1; ((A*)(B*)pObjD)->Funl(777); ((A*)(C*)pObjD)->xO = 2; ((A*)(C*)pObjD)->Funl(999);

Виртуальные функции и виртуальные классы Мы продолжаем модификацию последнего варианта нашей программы, добавляя к прототипу функции int A::Fun1(int); спецификатор virtual.

class A { public: int xO; virtual int Funl(int key); }; Результат выполнения программы можно предугадать. Функциячлен класса А становится виртуальной, а значит, замещается в соответствии с таблицей виртуальных функций в ходе сборки объектапредставителя класса-наследника D. В состав этого объекта включены два независимых друг от друга базовых фрагмента-представителя базового класса А. Количество таблиц виртуальных функций соответствует количеству базовых фрагментов, представителей базового класса, содержащего объявления виртуальных функций. Как бы мы ни старались, вызвать функцию-член класса А из любого фрагмента объекта-представителя класса D невозможно. Сначала конструкторы строят объект, настраивают таблицы виртуальных функций, а потом уже мы сами начинаем его "перекраивать", создавая на основе базового фрагмента видимость самостоятельного объекта. Напрасно. Объект построен, таблицы виртуальных функций также настроены. До конца жизни объекта виртуальные функции остаются недоступны. ___


Следует обратить особое внимание на то обстоятельство, что независимо от места вызова виртуальной функции (а мы её вызываем непосредственно из базовых фрагментов объекта), замещающей функции в качестве параметра передаётся корректное значение this указателя. Очевидно, что соответствующая корректировка значения этого указателя производится в процессе вызова замещающей функции. При этом возможны, по крайней мере, два различных подхода к реализации алгоритма корректировки. Соответствующее корректирующее значение может определяться в момент создания объекта конструкторами и храниться в виде константной величины вместе с таблицами виртуальных функций, либо this указатель может динамически настраиваться в момент вызова виртуальной функции благодаря специальному программному коду настройки этого указателя. Но это всё уже зависит от конкретной реализации языка. В этом разделе нам осталось обсудить понятие виртуального базового класса. Согласно соответствующей БНФ, спецификатор virtual может быть включён в описатель базы: ОписательБазы ::= ПолноеИияКласса ::= /Virtual7 [СпецификаторДоступа] ПолноеИмяКласса ::= fСпецификаторДоступа] [virtual] ПолноеИмяКласса

Модифицируем нашу программу. Мы добавим в описатели баз классов-наследников В и С спецификатор virtual: class A < public: int xO; int Funl(int key); class В: virtual public A { public: int xO; int Funl(int key); int Fun2(int key); class C: public virtual A { public: int xO; int Fun2(int key); class D: public B, public С < public: int xO;

"264


i n t Funl(int key); }; Этот спецификатор в описании базы позволяет минимизировать структуру создаваемого объекта. Виртуальные базовые классы не тиражируются. Вот как выглядит после модификации граф класса-наследника D:

А вот как меняется структура класса D, представляемая в виде неполной схемы.

D D MyD; А вот и схема объекта-представителя класса D. MyD : : = (int)xO; (int)xA; (int)xB; (int)xO; D (int)xO; (int)xD; Различные варианты обращения к данным-членам базового фрагмента приводят к модификации одних и тех же переменных. Базовый фрагмент объекта связан со своими производными фрагментами множеством путей, по которым с одинаковым успехом может быть обеспечен доступ к данным-членам базового фрагмента. 265


В C++ допускаются такие варианты объявления классовнаследников, при которых одни и те же классы одновременно выступают в роли виртуальных и невиртуальных базовых классов. Например, сам класс D может быть использован в качестве базового класса: c l a s s F: p u b l i c А

class G: public A, public D

G MyG;

Вложенные объявления классов Класс может быть объявлен внутри другого класса. Такой класс называют вложенным классом. Вложенный класс является локальным по отношению к внешнему или объемлющему классу. Вложенный класс объявляется непосредственно в объемлющем классе. Не допускается никаких предварительных объявлений вложенных классов. Транслятор должен всё знать о структуре внешнего класса непосредственно в момент трансляции. Определение функций-членов вложенного класса осуществляется либо непосредственно во вложенном классе, либо за пределами внешнего класса. Область видимости класса распространяется на имена, объявленные в классе. Возможное обращение к члену класса вне данного класса обеспечивается с помощью операции доступа ::. Вложенный класс можно воспринимать как класс-член включающего класса. При объявлении подобного класса не производится особого выделения памяти для этого класса. Объявление остаётся лишь объявлением и влияет только на область видимости имени вложенного класса. Память при этом выделяется лишь при создании экземпляра класса. Определяемые за пределами внешнего класса функции-члены вложенного класса имеют квалифицированные имена. При этом для обозначения функции-члена вложенного класса в ряде случаев достаточно неполного квалифицированного имени, состоящего из имени вложенного класса и имени соответствующей функции. class C1 { class I {

266


int Fl() int 13.0 int F2() ;

int I: :F2() () //Неполное кваливицированное имя функции-члена класса I. int Cl::I::Fl()(} // Полное квалифицированное имя. int C1::F2()(I iVall;} /* Функция-член класса С1 содержит объявление переменной "собственного" типа - типа I, вложенного в объявление класса С1. */ Здесь следует учитывать возможные неоднозначные ситуации. Если, например, в исходный файл будет добавлено объявление ещё одного класса, также содержащего собственную версию вложенного класса I, также содержащего объявления функций с именами F1 и (или) F2, то неполного квалифицированного имени для определения этих функций за пределами вложенного класса будет недостаточно. class Cl ( public: class I { int Fl() ; int F2 () ;

int ISO ;

int I: :F2() U //Неполное кваливицированное имя функции-члена класса I. int Cl::I::F1(){J // Полное квалифицированное имя. int C1::F2()(I iVall;} /* Функция-член класса Cl содержит объявление переменной "собственного" типа - типа I, вложенного в объявление класса С1. */ class C2 ( class I { int Fl() {}; // Встроенное определение не вызывает возражений / / у транслятора. int F2();

int F2() ; 267"


//int I: :F2() {} /* Ошибка. Неполное кваливицированное имя функции-члена класса I в данном контексте недостаточно. Транслятор не может однозначно определить принадлежность функции. */ int C2::I::F2(){)/* Полное квалифицированное имя содержит полную информацию о функции. Это функция-член класса I, который был объявлен в классе С2. */ int C2::F2{){ I iVall; С1::I iVal2; } /* функция-член класса С2 содержит объявление переменной "собственного" типа - типа I, вложенного в объявление класса С2. Спецификация доступа public в объявлении класса С1, располагаемая непосредственно перед объявлением вложенного класса I, позволяет определять переменные "чужого" типа. Квалифицированное имя класса устраняет неоднозначность. */

Рассмотрим ещё один простой пример объявления вложенного класса.

Самое интересное в этом примере заключается в том, что транслятор не допускает объявлений одноименных классов на смежных уровнях и при этом спокойно относится к объявлениям одноименных классов на более глубоких уровнях. Работа с вложенными классами требует большого внимания и, в буквальном смысле, ювелирной работы с операциями разрешения области видимости. В приводимом ниже примере показано, как можно вызывать конструктор из "внутреннего" класса для создания объекта внешнего класса. #include <iostream.h> class XXX ( public: class YYY _ _ _


public: int у; YYY(int py); YYY(int py, int px); ~YYY(); void yyylnfo(char *string); }; int x; XXX(int px, int py); XXX(int px); -XXXO ; // YYY *yyy_yyy; void xxxlnfo(char *string); }; XXX::YYY::YYY(int py) { у = py; cout « "This is XXX::YYY::YYY(int py):" « py « endl; > XXX::YYY::YYY(int px, int py) { (::XXX)(px); У = РУ; cout « "XXX: :YYY: : YYY (int,int) :" « px « " , " « py « endl ; ) XXX::YYY::~YYY() { cout « "Deleter XXX::YYY::~YYY():" « у « endl; } void XXX::YYY::yyylnfо(char *string) { cout « string « у « endl; } XXX::XXX(int px) < x = px; cout « "XXX::XXX(int):" « px « endl; //yyy_yyy • NULL; ) XXX::XXX(int px, int py) { X • px; ' cout « "XXX: : XXX (int, int):" « px « " , " « py « endl; //УУУ_УУУ = new (XXX::YYY)(py);

269


XXX: :~ХХХ() < cout « "Deleter }

XXX::~XXX():" «

x «

endl;

void XXX::xxxlnfo(char *string) { cout « string « x « endl; } void main() < XXX xxx(1,10); XXX::YYY yyy(2,20); yyy.yyyInfo("From yyy.. .") ; xxx.xxxInfo("From xxx..."); //xxx.yyy_yyy->yyylnfo("From xxx->yyy_yyy..."); cout « yyy.у « endl; cout « xxx.x « endl; )

Несколько строк в примере закомментированы. Это сделано для того, чтобы продемонстрировать необходимость специальных указателей для эффективной работы с объектами сложной структуры. Вложенная система типов и даже виртуозное обращение с операцией разрешения области видимости — ничто по сравнению с объявленным в нужном месте и в нужное время проинициализированным указателем. Особое внимание уделим организации доступа для вложенных классов. Ни вложенный, ни включающий классы не имеют никаких взаимных привилегий доступа по отношению к членам своих классов. К имени вложенного класса применяются те же правила, что и к другим даннымчленам. Если вложенный класс объявлен в секции private включающего класса, то его могут использовать только члены включающего класса. Члены включающего класса имеют непосредственный доступ к имени вложенного класса. Для обращения к членам вложенного класса членам включающего класса не требуется никаких дополнительных операций. А вот функции и классы, которые не принадлежат включающему классу, для доступа к вложенному классу должны использовать операции доступа. Вот где настоящее царство составных квалифицированных имён!

Указатели на компоненты класса. Доступ по указателю Прежде всего, рассмотрим объявление класса XXX. c l a s s XXX

__


public: long xl; int x2; /•Данные-члены

класса.*/

long g e t V a l l O ( r e t u r n x l ; } long getVal2() { r e t u r n x 2 * x l ; } /*Функции-члены класса без параметров.*/ int getVal3(int param) {return x2*param;} char* getVal4(char *str) {return str;} /*Функции-члены класса с параметрами.*/ static int fl() (return 100;} static int f2() {return 10;} static int f3(int param) {return param;} /* Определение различных статических функций*/ XXXdong vail, int val2) {xl = vail; x2 = val2;} /*Конструктор.*/ }; Поскольку нестатические функции-члены формально, а нестатические данные-члены фактически не существуют без объекта-представителя класса, определение указателя на компонент класса (член класса или функцию-член) отличается от определения указателя на объект или обычную функцию. Для объявления указателя на нестатическую функцию используется специальная синтаксическая конструкция, состоящая из спецификатора объявления и заключённого в скобки квалифицированного имени указателя, состоящего из имени класса, операции доступа к члену класса ::, разделителя * , собственно имени указателя, закрывающей скобки и списка параметров: int

(XXX::*fp_3)

(int);

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

(XXX::*fp_3)

( i n t ) = &ХХХ::getVall;

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


первичное выражение, представляющее собой квалифицированное имя функции-члена: Fp_3 = XXX::getVal2 Класс - это не объект! И не совсем понятно, какое значение имеет адрес нестатичесного члена класса. Значение проинициализированного указателя на нестатическую компоненту остаётся неопределённым. Оно определяется лишь в результате выполнения операций обращения к членам класса .* и ->* . При этом функция-член класса вызывается по указателю на компоненту относительно конкретного объекта или указателя на объектпредставитель класса. Первым операндом операций обращения к членам класса является 1-выражение, ссылающееся на объект (возможно, что имя объекта) или указатель на объект, вторым операндом является ссылка на указатель на компоненту класса: int val = (q.*fp) (6) ; char val = (pq->*fp4)("new string"); Аналогичным образом осуществляется объявление и инициализация указателей на данные-члены класса. При этом структура объявления указателя на член класса проще (нет спецификации возвращаемого значения, не нужно указывать список параметров). Это не функция, здесь дело обходится спецификацией объявления и квалифицированными именами указателей: long (XXX::*pxl) = &ХХХ::х1; /* Определение и инициализация указателя на член класса XXX типа long */ q.*pxll = 1 0 ; // р - объект-представитель класса XXX. pq->*pxll = 10; // РЧ ~ указатель на объект-представитель класса XXX. Основные приёмы работы с указателями на функции-члены демонстрируются на следующих примерах: class XXX { public: long xl; int x2; /•Данные-члены класса.*/ long getVall() (return xl;} __


long getVal2<) {return x2*xl;} /*Функции-члены класса без параметров.*/ int getVal3(int param) {return x2*param;} char* getVal4(char *str) {return str;} /*Функции-члены класса с параметрами.*/ static int fl() {return 100;} static int f2() {return 10;} static int f3(int param) {return param;} /* Определение различных статических функций*/ XXX(long vail, int val2){xl = vail; x2 = val2;} /•Конструктор.*/ void main() { XXX q(l,2);/* Определение объекта.*/ XXX* pq = new (XXX); pq->xl = 100; pq->x2 = 100; /•Определение и инициализация объекта по указателю.*/ long (XXX::*fp_0) (); /•Указатель на функцию-член класса.*/ long (XXX::*fp_l) () - &ХХХ::getVall; /* Проинициализированный указатель на функцию-член класса. Его Значение является относительной величиной и равняется значению смещения функции-члена относительно первого члена класса. */ fp_0 = XXX::getVall; /* Инициализация первого указателя. Один и тот же указатель можно настраивать на различные функции-члены класса. Главное, чтобы у всех этих функций-членов совпадали списки параметров и возвращаемые значения функций. */ long val_l = (q.*fpl) () ; /•Вызов функции-члена класса по указателю из объекта.*/ long val_2 = (pq->*fpO)(); /* Вызов функции-члена класса по указателю с помощью указателя на объект. */ int (XXX::*fp_3) (int) = &ХХХ::getVal3; /* Проинициализированный указатель на функцию-член класса. С параметрами типа int. */ 273"


int val_3 = (q.*fp_3)(6); /* Вызов функции-члена класса по указателю из объекта с передачей параметров. * I char* (XXX::*fp_4) (char) = &ХХХ::getVal3; /* Проинициализированный указатель на функцию-член класса с параметрами типа int. */ char val_4 = (pq->*fp4)("new string"); /* Вызов функции-члена класса по указателю с помощью указателя на объект. */ int (*fp_5) () = &XXX::fl; /* Указатель на статическую функцию объявляется без спецификации класса. Явная спецификация класса необходима лишь при инициализации указателя. */ int retval = (*fp_5)(); /*Вызов статической функции по указателю.*/ fp_5 = XXX::f2; /* Перенастройка статического указателя. Главное требование - совпадение списков параметров и типа возвращаемого значения. */ int (*fp_6) (int) = &XXX::f3; /•Указатель на статическую функцию с параметрами.*/ int retval = (*fp_6)(255); /*Вызов статической функции с параметрами по указателю.*/ long (XXX::*pxl) = &ХХХ::х1; /•Определили и проинициализировали указатель на член класса long*/ q.*pxll = 10; /•Используя указатель на компоненту класса, изменили значение переменной xl объекта q, представляющего класс XXX. */ pq->*pxll = 10; /•Используя указатель на компоненту класса, изменили значение переменной xl объекта, представляющего класс XXX и расположенного по адресу pq. */ } Вызов статических функций-членов класса не требует никаких объектов и указателей на объекты. От обычных функций их отличает лишь специфическая область видимости. _ _


Объединения Наконец, мы переходим к объединениям. Наш уровень знаний делает знакомство с этой конструкцией приятным и лёгким. Представляющий объединение объект занимает область памяти, размер которой достаточен для сохранения только одного из данныхчленов объединения. Несмотря на то, что объединение не предназначается для экономии памяти, в нём нет ничего лишнего. Отсюда его основные свойства и особенности: И объединение может включать функции-члены, в том числе конструкторы и деструкторы. Они, безусловно, могут быть полезны для обслуживания единственного значения объекта-представителя объединения; • объединение не может иметь базовых классов и само также не может служить базовым классом. По этой причине в объединения не могут входить виртуальные функции (они бесполезны); • объединения также не могут включать статические данные-члены, объекты-представители некоторого класса со специально объявленными конструкторами, деструкторами, операторными функциями присваивания. Всё это служит помехой компактному сохранению значений. Неименованное объединение определяет объект, а не объявляет тип. Имена членов безымянного объединения должны отличаться от других имён из области действия, где было объявлено это объединение. Безымянное объединение не содержит объявления функций-членов. В своей области действия имена членов объединения используются непосредственно без обычных операций обращения. Глобальное безымянное объединение объявляется как статическое. Всякий раз это всего лишь универсальный многофункциональный "контейнер" для хранения значений различных типов в одной и той же области памяти. Этот раздел мы завершим примером, который раскрывает неожиданные возможности использования объединения. Сочетание объединения с битовым полем позволит нам убедиться в корректности преобразований дробной части вещественного числа. Здесь самое время обратиться к соответствующему приложению. #include <iostream.h> union { float floatVal; struct < int bitO int bitl

: 1; : 1; 275


int bit2 int bit3 int bit4 int bit5 int bit6 int bit7 int bit8 int bit9 int bitlO int bitll int bitl2 int bitl3 int bitl4 int bitl5 int bitl6 int bitl7 int bitl8 int bitl9 int bit20 int bit21 int bit22 int bit23 int bit24 int bit25 int bit26 int bit27 int bit28 int bit29 int bit30 int bit31 } BitField; } MyUnion;

1; 1; 1; 1; 1; 1; 1; 1; 1;

1; 1; 1;

1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1; 1;

void main () { MyUnion.BitField. bit31 MyUnion.BitField. bit30 MyUnion.BitField. bit29 MyUnion.BitField. bit28 MyUnion.BitField. bit27 MyUnion.BitField. bit26 MyUnion.BitField. bit25 MyUnion.BitField. bit24 MyUnion.BitField. bit23 MyUnion.BitField. bit22 MyUnion.BitField. bit21 MyUnion.BitField. bit20 MyUnion.BitField. bitl9 276

= = = = = = ш = = • = = =

0; 1; 0; 0; 0; 0; 1; 1; 0; 0; 1; 1; 0;


MyUnion.BitField.bitl8 MyUnion.BitField.bi.tl7 MyUnion.BitField.bitl6 MyUnion.BitField.bitl5 MyUnion.BitField.bitU MyUnion.BitField.bitl3 MyUnion.BitField.bitl2 MyUnion.BitField.bitll MyUnion.BitField.bitlO MyUnion.BitField.bit9 MyUnion.BitField.bit8 MyUnion.BitField.bit7 MyUnion.BitField.bit6 MyUnion.BitField.bit5 MyUnion.BitField.bit4 MyUnion.BitField.bit3 MyUnion.BitField.bit2 MyUnion.BitField.bitl MyUnion.BitField.bitO cout «

= = = = = = = = = = = = = = = = = = =

0; 1; 0; 0; 0; 1; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0; 0;

MyUnion.floatVal «

endl;

Принципы объектно-ориентированного программирования. Реализация Мы познакомились с принципом наследования — одним из трёх основных принципов объектно-ориентированного программирования. В C++ наследование реализуется через механизмы наследования и виртуальные классы, которые позволяют строить новые классы на основе ранее объявленных базовых классов. Благодаря наследованию программирование становится подобным процессу сборки сложных устройств и механизмов из наборов стандартных узлов и деталей. Инкапсуляция — второй принцип объектно-ориентированного программирования, делает процесс программирования на C++ ещё более похожим на работу в сборочном цехе. Хорошо спроектированный класс имеет открытый интерфейс для взаимодействия с "внешним миром" и защищенную от случайного воздействия "внутреннюю" часть. Такой класс подобен автомобильному двигателю. В момент его установки в кузове или на раме при сборке автомобиля, он уже полностью собран. И не нужно сверлить в корпусе двигателя дополнительные отверстия для подсоединения трубопроводов системы охлаждения, подачи топлива и машинного масла. В C++ разделение класса на скрытую внутреннюю часть и открытый интерфейс обеспечивается системой управления доступом к компонентам класса и дружественными функциями.

277


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

Операторные функции. Представление операций для классов Классы вводят в программу новые типы. Такие типы могут входить в списки параметров функций и определять тип возвращаемого значения. В вызовах функций при передаче параметров и возвращении значений данные новых типов используются в программе наравне с данными основных типов. В условиях фактического равноправия основных и производных типов данных должна существовать возможность сохранения привычной структуры выражений при работе с данными производных типов. Это означает, что выражение для вычисления суммы двух слагаемых уже известного нам типа ComplexType по своей структуре не должно отличаться от соответствующих выражений для слагаемых типа int или float. Но большинство операций языка C++ определены лишь для основных типов. В качестве операндов операций невозможно использовать выражения производных типов. Поэтому в классе ComplexType и приходится определять специальные функции-члены, реализующие арифметические операции над множеством комплексных чисел. И всё же возможность сохранения привычной структуры выражений для производных типов в C++ существует. Вернёмся к известному классу ComplexType. Мы определим два объекта класса ComplexType, после чего воспользуемся операцией присвоения. ComplexType ctVall(3.14, 0.712); ComplexType ctVal2, ctVal3; /* Комплексные числа со случайными значениями данныхчленов.*/ __


ctVal2 = ctVall; ctVal3 = ctVal2 = c t V a l l ; / * Операция п р и с в о е н и я к о м м у т а т и в н а . * / Если теперь вывести значения данных-членов объектов ctVal2 и ctVal3, то окажется, что они полностью совпадают со значениями данныхчленов объекта ctVall. Операция присваивания изначально определена для объектов класса ComplexType. Её можно рассматривать как предопределённую операцию, которая обеспечивает фактически побитовое копирование объекта, стоящего справа от символа = в объект, расположенный слева от этого знака. Подобно любому другому выражению, выражение присваивания имеет собственное значение. Это значение соответствует результату выполнения операции присваивания (с учетом возможных преобразований). В ходе выполнения программы изменяется значение 1-выражения в выражении присваивания, после чего значение этого выражения оказывается равным изменённому значению этого самого 1-выражения. Строго говоря, операцию присваивания для объектов производных типов нельзя называть операцией. Как и ранее рассмотренные нами "операции" приведения, она является операторной функцией. Это значит, что при её объявлении используется специальное имя, состоящее из ключевого слова operator с символом операции, а для её вызова можно использовать полную и сокращённую форму. Мы приступаем к очередной модификации объявления класса ComplexType с целью переопределения новой операторной функции, реализующей то, что можно называть "операцией присваивания". Работу по объявлению этой функции мы начнём с того, что попытаемся представить её общий вид: • функция объявляется как нестатический член класса и вызывается для объекта, которому надо присвоить соответствующее значение; • её имя состоит из ключевого слова operator, за которым, очевидно, следует символ = ; • основное назначение функции состоит в присваивании значения (множества значений) одного объекта другому. Следовательно, операторная функция operator=() должна иметь, по крайней мере, один параметр, который должен представлять присваиваемое значение; • и последнее, очень важное обстоятельство. Операторная функция operator=() должна возвращать новое значение объекта и по форме вызова должна создавать видимость коммутативности, поскольку этим свойством обладает операция присвоения. c l a s s ComplexType 279


ComplexType operator = (const ComplexType & ) ; /* Ссылка на константу при объявлении параметра не является обязательным условием для объявления операторной функции. Но это гарантия того, что присваиваемое значение не будет изменено в результате обращения к данным-членам. Операторная функция operator=() возвращает значение (именно значение!) объект класса ComplexType. Это не самый оптимальный способ обеспечения коммутативности операторной функции. Но при этом обеспечивается подобие операторной функции операции присваивания. */

ComplexType ComplexType::operator = (const ComplexTypeS ctKey) { cout « "This is operator = (ComplexTypeS ctKey)..." « endl; /* Подтверждение о том, что выполняется именно эта функция. */ this->real «« ctKey.real; this->imag = ctKey.imag; /* Теперь вся ответственность за корректность процесса копирования целиком и полностью возлагается на программиста. */ return *this; /* Мы возвращаем значение объекта, представленного this указателем. */

/* Будем считать, что объекты ctVall и ctVal2 уже определены. Осталось рассмотреть варианты вызовов этой функции. */ ctVal2.operator = (ctVall); /* Вариант полной формы вызова функции.*/ ctVal2 = ctVall; /* Вариант сокращённой формы вызова функции. Операция выбора, ключевое слово operator в составном имени операторной функции и скобки, заключающие выражение, представляющее значение параметра опускаются. Создаётся иллюзия использования обычной операции присваивания. */

280


/* Демонстрация коммутативности операторной функции присваивания. */ ctVal3.operator = (ctVal2.operator = (ctVall)); /* Операторная функция operator=() вызывается непосредственно из объекта ctVal3 со значением атрибута (ссылкой на объект), который сан в свою очередь является результатом применения операторной функции operator=() к объекту ctVal2 с параметром-ссылкой на объект ctVall. Всё очень просто и красиво! */ ctVal3 = ctVal2 = ctVall; /* Сокращённая форма коммутативного вызова операторной функции присваивания. */ При объявлении и определении операторных функций (в том числе и operator=()), используется синтаксическая конструкция, обозначаемая в терминах формальной грамматики нетерминальным символом ИмяФункцииОперации. Несколько форм Бэкуса-Наура позволяют однозначно определить это понятие: Имя : : = ИмяФункцииОперации : ; = ***** ИмяФункцииОперации : : — o p e r a t o r СимволОперации СимволОперации : : = + | - | * | ? | % | л | 6 | ~ | ! I , | = | < | > | < = | > = | + + | - - | « | » | = = | ! = |&&| I I | + = | - = | * = | « = | » = | [] | () | - > | - > * | new | d e l e t e | Как следует из приведённых БНФ, большинство символов операций языка C++ могут участвовать в создании так называемых имён функций операций или операторных функций. То есть на основе этих символов можно объявлять операторные функции, сокращённая форма вызова которых позволяет создавать видимость применения операций к объектам производных типов. C++ не накладывает никаких ограничений на семантику этих самых операторных функций. Наша операторная функция operator=() могла бы вообще не заниматься присвоением значений данных-членов. Она могла бы не возвращать никаких значений. Само собой, что тогда выражение вызова этой функции не могло бы быть коммутативным. А единственный параметр можно было бы передавать по значению. Но тогда всякий раз при вызове функции неизбежно должен был бы вызываться конструктор копирования, который бы создавал в области активации функции копию объекта, которую впоследствии должен был бы разрушать деструктор. Операторная функция operator=(), как и любая другая функция, может быть перегружена. Например, объявление параметра типа int, позволило бы присваивать комплексным числам целочисленные значения. Здесь нет пределов совершенствования. В принципе, механизм операторных функций регламентирует лишь внешний вид заголовка функции (его "операторное" имя, количество параметров, в ряде случаев - возвращае281


мое значение). Информация о заголовке принципиальна, поскольку от этого зависит форма сокращённого вызова операторной функции. Ещё несколько замечаний по поводу спецификации возвращаемого значения операторной функции. Операторная функция operator=() может вообще не возвращать никаких значений. Сокращённая форма вызова ctVal2 = c t V a l l ; с точки зрения транслятора абсолютно корректна и полностью соответствует следующим прототипам: void ComplexType::operator void ComplexType::operator void ComplexType::operator

= (const ComplexTypefi ctKey); = (ComplexTypefi ctKey); = (ComplexType ctKey);

Правда, в таком случае не имеет смысла говорить ни о коммутативности, ни о "безопасности", но об эффективности вновь определяемой операторной функции. С другой стороны, уже существующий вариант нашей операторной функции также может быть оптимизирован. Функция может возвращать не ОБЪЕКТ (ЗНАЧЕНИЕ), а ССЫЛКУ на объект. В этом случае при возвращении значения не будет создаваться временный объект. Также не будет вызываться деструктор для его уничтожения. Модификация операторной функции operator=() минимальна всего лишь дополнительная рЮперация & в спецификации возвращаемого значения (мы приводим здесь только прототип новой версии функции): ComplexTypeS o p e r a t o r = ( c o n s t ComplexType

&);

Всё остальное транслятор исправит самостоятельно, так что никаких дополнительных модификаций в тексте программы производить не придётся. Эта функция будет эффективней, правда, семантика выражения её вызова будет отличаться от семантики соответствующего выражения присвоения с базовыми типами. В первом случае результатом выполнения выражения оказывается присваиваемое значение, во втором - ссылка на объект. Следующий пример посвящен объявлению и вызову различных вариантов операторных функций operator(). Он подтверждает тот факт, что при объявлении операторных функций не существует никаких ограничений на семантику операторных функций: ComplexType& operator () (const ComplexType&); /* Первый вариант совместно используемой функции operator().*/ void operator () (int) ; /* Второй вариант совместно используемой функции operator О . * / _ _


/* Определения этих функций. Как всегда, они не делают ничего полезного... */ ComplexType& ComplexType::operator () (const ComplexType fictKey) { cout « "This is operator (ComplexTypeS ctKey)..." « endl; return *this; void ComplexType::operator () (int iKey) < cout « "This is operator ( " « iKey «

" ) ..." «

endl;

/* Полные и сокращённые формы вызова этих функций. Первая операторная функция коммутативна. */ CDw2.operator()(CDwl); CDw2(CDwl); CDw3.operator()(CDw2.operator()(CDwl)); CDw3(CDw2(CDwl)); CDw2 . operator () (25) ; CDw2(50);

И это ещё не всё! Ещё не рассматривались варианты операторной функции operator() с несколькими параметрами. И здесь следует вспомнить о функциях с переменным количеством параметров. Это не единственный, но наиболее оптимальный подход к объявлению операторной функции operator() с несколькими параметрами. Здесь мы не будем вдаваться в детали алгоритма извлечения информации из списка параметров (мы их уже обсуждали раньше), а ограничимся лишь общей схемой объявления и вариантами выражения вызова. В нашей версии (всего лишь одной из возможных!), первым параметром функции всегда будет целое число: ComplexTypeS operator () (int, ...) ;// Прототип.

ComplexTypeS ComplexType::operator () (int iKey, ...) < cout « "This is operator ( " « iKey « ", . . . ) " « endl; return *this;

CDw2(50) ;

283


CDw2<50, 100); CDw2(50, "Это тоже вызов операторной функции", 3.14, 0,123456789);

В C++ может быть объявлено более трёх десятков различных вариантов операторных функций. К этому выводу приводит анализ списка символов операций, которые потенциально могут входить в качестве элемента имени операции. Не имеет смысла описывать все возможные операторные функции по отдельности. Определение большинства операторных функций подчиняется общим правилам. Здесь мы рассмотрим лишь несколько интересных "нетипичных" случаев объявления. В следующих разделах будут описаны типичные схемы объявлений операторных функций. Как известно, операция косвенного выбора -> является бинарной операцией. Её первым операндом является указатель на объект, вторым имя члена класса. В C++ соответствующий операторный аналог представляется функцией без параметров. Кроме того, для этой функции регламентируется тип возвращаемого значения. Она должна обязательно возвращать указатель либо ссылку на объект некоторого класса. Рассмотрим различные варианты объявления, определения и вызова этой операторной функции. Первый вариант тривиален:

ComplexType* o p e r a t o r -> ( ) ;

ComplexType* C o m p l e x T y p e : : o p e r a t o r -> () { c o u t « " T h i s i s o p e r a t o r -> ( ) . . . " « e n d l ; return this;

Таково, в общих чертах, объявление и определение функции. Функция без параметров.

if (CDw2.operator->() == NULL) cout «

"!!!" « endl;

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


CDw3->real = 125.07; (CDw3.operator->())->real = 125.07;

Сокращённая и полная формы вызова операторной функции в составе оператора присвоения. Функция возвращает адрес, к которому применяется обычная двухместная операция косвенного выбора. А вот более простого варианта сокращённой формы вызова функции operator->(), наподобие того, который ранее использовался в составе условного оператора, в C++ не существует. Правильно построенных выражений вида (xObject->) с единственным операндом, где - > является символом операции, в C++ нет, поскольку - > бинарная операция. Из-за того, что не всегда удаётся различить по контексту выражение вызова функции и операцию косвенного выбора, сокращённый вызов операторной функции operator->() используется исключительно для имитации выражений с операцией косвенного выбора. Операторная функция operator->() возвращает указатель на объект, и как любая нестатическая функция-член класса должна вызываться непосредственно "из объекта". Эта прописная истина не представляла бы никакого интереса, если бы в C++ существовали жёсткие ограничения на тип возвращаемого значения функции-члена класса. Но таких ограничений для операторных функций в C++ не существует, а потому возможны и такие экзотические варианты операторных функций:

class CoraplexType

class rrr // Объявляется новый класс. { public: ComplexType*

pComplexVal;

// Собственные версии конструкторов и деструкторов. rrr () __


pComplexVal = new ComplexType; // Порождение собственного экземпляра объекта ComplexType.

-rrr () { if (pComplexVal) = delete pComplexVal;

// Наконец, встроенная операторная функция. ComplexType* operator -> () { cout « "This is operator -> ( ) . . . " « endl; return pComplexVal;

/ / А это уже собственно фрагмент программы... rrr rrrVal; // Определяем объект - представитель класса rrr. cout «

rrrVal ->real «

" real." «

endl;

Сокращённая форма вызова операторной функции operator->() имеет вид rrrVal->real и интерпретируется транслятором как (rrrVal.operator>())->real, о чём и свидетельствует оператор, содержащий полную форму вызова этой операторной функции.

cout «

(rrrVal.operator->())->imag «

" imag." «

endl;

В этом случае из объекта-представителя класса rrr вызывается операторная функция, в обязательном порядке возвращающая адрес объекта-представителя класса ComplexType, к которому сразу же (!) применяется операция косвенного обращения. Здесь мы рассмотрели три операторные функции, сокращённая форма вызова которых имитировала операции присвоения, вызова и косвенного обращения. Эти операторные функции занимают особое место среди прочих операторных функций. _ _


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

Стандартные формы бинарных операторных функций Нам осталось рассмотреть общие формы объявления, определения и вызова ещё двух десятков (или около того) операторных функций. Для решения этой задачи мы разделим оставшееся множество операций на несколько подмножеств. Сводная таблица операций представлена ниже. Для каждой из приведённых в таблице операций существует функциональный аналог. Причём каждая из операторных функций может быть объявлена и определена несколькими способами: • как функция-член объявляемого класса, • как обычная, возможно, дружественная функция. Бинарные операции Прочие Сравнения и логические &&|| + - * %Л / & | < > < = > = == 1= « » «= »= += .= -= о/о= /=& ->*

=

Унарные операции Префиксные Постфиксные ++ — -+

++

|= л =

п

Если операторная функция определяется как функция-член класса, количество её параметров, за несколькими исключениями, обязано быть на единицу меньше количества операндов соответствующей операции. Здесь ничего нового: операторная функция-член класса вызывается из объекта и значения данных-членов этого объекта всегда рассматривается как операнд. Второй операнд (если таковой существует) в этом случае оказывается единственным параметром. Про первые два исключения из этого правила нам уже известно. Это операции косвенного обращения и вызова. Третье исключение касается унарных операций инкремента и декремента, которые мы рассмотрим позже. 287


Рассмотрим примеры определения и объявления операторных функций как функций-членов класса. Доопределим класс ComplexType, добавив операторные функции. class ComplexType { public: /* Прототипы операторных функций, реализующих арифметические действия с комплексными числами. Функции возвращают значение типа ComplexType, и имеют единственный параметр типа ComplexType. */ ComplexType operator + (ComplexTypes); ComplexType operator * (ComplexTypeS); ComplexType operator / (ComplexTypeS);

/* Определения операторных функций-членов класса ComplexType... */ ComplexType ComplexType::operator +(ComplexTypeS b) { ComplexType temp; temp.real = this-> real + b.real; temp.imag = this->imag + b.imag; return temp; ComplexType ComplexType::operator *(ComplexTypeS b) { ComplexType temp; temp.real = this->real*b.real - this->imag*b.imag; temp.imag = this->real*b.imag + this->imag*b.real; return temp;

I

ComplexType ComplexType::operator /(ComplexTypeS b) { ComplexType temp; temp.real = (a.real*b.real+a.imag*b.imag)/(b.real*b.real+b.imag*b.imag); temp.imag = (b.real*a.imag-a.real*b.imag)/(b.real*b.real+b.imag*b.imag); return temp; > Полная форма вызова операторных функций-членов класса: CDwl = CDwl.operator CDw3 = CDwl.operator CDw2 = CDw3.operator

* (CDw2); + (CDw3); / (CDw2);

Сокращённая форма вызова операторных функций, как и следовало ожидать, приобретает вид выражений с операндами основных типов: 288


CDwl P CDwl * CDw2; CDw3 = CDwl + CDw3; CDw2 = CDw3 / CDw2; Мы определили несколько операторных функций, которые имитируют арифметические операции над множеством комплексных чисел. Областью определения этих операторных функций является множество комплексных чисел - объектов, представляющих класс ComplexType. Это означает, что в выражении сокращённой формы вызова операторной функции, справа и слева от символа, имитирующего операцию, должны располагаться выражения, представляющие объекты одного и того же типа. Однако, как известно, операции для базовых типов могут иметь операнды различных типов. В этом случае, при вычислении значений выражений, используются правила преобразования типов, либо операции явного преобразования. В мире операторных функций также можно строить выражения сокращённого вызова для объектов-представителей разных типов. Можно определить несколько совместно используемых операторных функций с параметрами различных типов. Разумеется, вся ответственность за корректность определения этих функций возлагается на программиста. Именно поэтому мы ограничимся лишь общими схемами объявления и определения этих функций. /* Прототипы операторных функций, реализующих сложение комплексного числа с целым и вещественным слагаемым. Функции возвращают значение типа ComplexType. */ ComplexType operator + (intS); ComplexType operator + (floats);

ComplexType ComplexType::operator +(intS intVal) { ComplexType temp; temp.real = this->real + intVal; return temp; ComplexType ComplexType::operator +(floats floatVal) { ComplexType temp; temp.real = this->real + floatVal; return temp;

289


/* Очевидно, что после этого не возникает никаких проблем с трансляцией следующих операторов. */ CDw3 = CDwl + 25; CDw3 = CDwl + 3 . 1 4 ; Ещё один вариант решения проблемы операторных функций для операндов различных типов заключается в объявлении и определении конструкторов преобразования и функций приведения (как давно это было!). Например, после определения такого конструктора преобразования:

ComplexType(int iVal) { real = double(iVal); imag • 0 . 0 ;

При трансляции оператора CDw3 = CDwl + 25; /* Преобразование типа Здесь производится по умолчанию.*/ будет организован "вызов" конструктора преобразования для целочисленного значения 25. На этапе выполнения программы будет создан временный объект типа ComplexType, после чего будет вызвана операторная функция ComplexType operator + (ComplexType S ) ; Конструкторы преобразования и функции приведения позволяют создавать видимость симметричности операторных функций-членов класса. Например, в ходе трансляции оператора CDw3 = (ComplexType)25 + CDwl; /* З д е с ь я в н о е п р е о б р а з о в а н и е т и п а о б я з а т е л ь н о ! */ благодаря операции явного преобразования, вызывается конструктор преобразования для целочисленного значения 25, после чего, на основе цезначения 25 создаётся временный объект-представитель _лочисленного _


класса ComplexType (временный объект отличается от прочих объектовпредставителей данного класса лишь временем жизни и областью видимости), для которого и будет вызвана наша операторная функция. При всём разнообразии возможных решений важно соблюдать одно очень важное правило: ВСЕГДА ДОЛЖНО СОХРАНЯТЬСЯ ОДНОЗНАЧНОЕ СООТВЕТСТВИЕ МЕЖДУ ВЫРАЖЕНИЯМИ ВЫЗОВА И ОПРЕДЕЛЕНИЯМИ ФУНКЦИЙ. Впрочем, и здесь для нас нет уже ничего нового. Остаётся рассмотреть пример. tinclude <iostream.h> class ComplexType { public: ComplexType(double re = 0.0, double im m 0.0) { real = re; imag • im; } ComplexType(int val) { real = (double)val; imag = 0.0; cout « "ComplexType(" « }

val «

-ComplexType() ( cout « "~ComplexType():" «

")..." «

real «

endl;

"," «

imag «

endl;

double real; double imag; ComplexType operator + (ComplexType SKeyVal) { ComplexType temp; temp.real = real + KeyVal.real; temp.imag = imag + Key Val.imag; r e t u r n temp;

void main () __


ComplexType CTwl, CTw2(1.0,1.0), CTw3(2.0,2.0) ; CTwl = CTw2 + CTw3; CTwl = CTw2 + 10; // Здесь всё происходит само собой. CTwl = (ComplexType)20 + CTw3; // Здесь требуется подсказка. CTwl = 30 + 40; /* Сначала вычисляется сумма целых. Затем вызывается конструктор преобразования. И никаких операторных функций.*/ } Операторные функции служат для поддержки иллюзии. Это всего лишь имитаторы. И потому транслятор не позволяет определять операторные функции с числом операторов, превышающих размерность их операций-аналогов. И это понятно, поскольку использование подобных операторных функций было бы лишено всякого смысла. Рассмотрим ещё один способ объявления операторных функций. При этом операторные функции объявляются как обычные глобальные (возможно дружественные) функции. Они не являются функциями-членами класса, и потому количество параметров таких функций обычно соответствует (за редким исключением) числу операций. В качестве примера приведём вариант определения операторных функций, имитирующих различные варианты операции сложения для комплексных чисел: class ComplexType /* Прототипы операторных функций, реализующих действие сложение для комплексных чисел... */ friend ComplexType operator + (ComplexTypeS, ComplexTypeS); friend ComplexType operator + (ComplexTypeS, int); friend ComplexType operator + (int, ComplexType&);

/* Определение операторных функций, реализующих варианты действия сложения для комплексных чисел. */ ComplexType operator + (ComplexTypeS a, ComplexTypeS Ь) { ComplexType temp; temp.real = a.real + b.real; temp.imag = a.imag + b.imag; return temp; ComplexType operator + (ComplexType& a, int b)

292


ComplexType temp; temp.real = a . r e a l + b; temp.imag = a . i m a g ; r e t u r n temp; ComplexType o p e r a t o r + ( i n t b , ComplexTypefi a) { ComplexType temp; temp.real = a . r e a l + b; temp.imag = a.imag; r e t u r n temp;

При этом полные формы вызова операторных функций приобретают вид: CDw3 = operator + (operator + (CDwl, CDw2),50); CDw3 = operator + (25, CDw2); CDw3 = operator + (CDwl, 100); А краткие формы вызова позволяют создать иллюзию полноценной симметричной и транзитивной операции: CDw3 = CDwl + CDw2 + 50; CDw3 = 25 + CDw2; CDw3 = CDwl + 100; Операторные функции-аналоги логических операций и операций сравнения отличаются от ранее рассмотренных операторных функций возвращаемыми значениями. И ещё, пожалуй, новыми проблемами из области философии. Это для комплексных чисел достаточно просто реализовать алгоритмы сравнения: int operator == (ComplexType fictVal);

int ComplexType::operator == (ComplexType &ctVal) { if (real == ctVal.real && imag == ctVal.imag) return 1; else return 0; ) А как сравнивать между собой объекты-представители классов сложной структуры с невиртуальным множественным наследованием? В 293"


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

ComplexType o p e r a t o r -

О;

ComplexType ComplexType::operator

- ()

real = -real; imag = -imag; return * thi s; } Теперь - как обычную функцию:

ComplexType operator - (ComplexType);

ComplexType operator - (ComplexType ComplexType temp ; temp.real = -KeyVal.real; temp.imag = -KeyVal.imag; return temp;

294

SKeyVal)


/ / Полные формы в ы з о в а э т и х функций р а з л и ч а ю т с я : CTwl = C T w l . o p e r a t o r - ( ) ; CTwl = o p e r a t o r - (CTwl); // А в о т сокращённые формы р а з л и ч и т ь невозможно: CTwl = -CTwl;

Если в программе используются сокращённые формы вызова, то, очевидно, что в одном файле может быть объявлен лишь один вариант данной операторной функции. Иначе транслятор просто не сможет корректно соотнести выражение вызова и соответствующую операторную функцию. Отождествление сокращённых форм вызова функции инвертирования (унарный минус) и функции вычитания не вызывает никаких затруднений. У этих функций разное количество параметров: CTwl = CTwl — CTw2;// Вызов операторной функции вычитания. CTwl = -CTwl; // Вызов операторной функции инвертирования. А вот и обещанные исключения. Они связаны с операторными функциями инкремента и декремента. Имитация операций инкрементации и декрементации предполагает как префиксную (++CTw1), так и постфиксную (CTw1++) формы. И вот здесь-то и используется дополнительный параметр при объявлении операторных функций. Рассмотрим примеры. class ComplexType

/* Имитация префиксной формы инкремента. Функция объявляется как встроенная функция-член класса. */ ComplexType operator ++ () { real++; imag++; /* Меняем значения членов, возвращаем НОВОЕ изменённое значение. */ return *this; /* Имитация постфиксной формы инкремента, функция объявляется как встроенная функция-член класса. У неё непустой список параметров, причём объявленный параметр не используется и потому может оставаться безымянным. */ _


ComplexType operator ++ (int) { ComplexType temp = *this; // Сохраняем старое значение. real++; imag++; /* Меняем значения членов, возвращаем СТАРОЕ сохранённое значение. */ return temp;

Здесь же приведём примеры объявления и определения имитаторов декремента для комплексных чисел. На этот раз это будут обычные функции. Сначала прототипы: ComplexType operator — ComplexType operator —

(ComplexType & ) ; // Префиксная форма. (ComplexType S, int);// Постфиксная форма.

Теперь определения: /* Имитация префиксной формы декремента. */ ComplexType operator — (ComplexType SkeyVal) { keyVal.real—; keyVal.imag—; /* Меняем значения членов, возвращаем НОВОЕ изменённое значение. */ return keyVal; > /* Имитация постфиксной формы декремента. И опять лишний безымянный параметр. */ ComplexType operator — (ComplexType SkeyVal, int) < ComplexType temp = keyVal; // Сохраняем старое значение. keyVal. r e a l — ; keyVal.imag—; /* Меняем значения членов, возвращаем СТАРОЕ сохранённое значение. */ return temp; } Роль дополнительного параметра становится очевидна при анализе полных и сокращённых форм вызова этих операторных функций. Так, полные формы вызова префиксных операций имеют такой вид: CTwl.operator ++ (); // Инкремента как функции-члена класса. _ _


operator —

(CTwl);

// Декремента как обычной функции.

Вот как выглядят аналогичные сокращённые формы вызова. Транслятор прекрасно понимает, о чём идёт речь... ++CTwl; —CTwl; А теперь — варианты вызова постфиксных операторных функций. Сначала - сокращённые формы вызова: CTwl++; CTwl—; Эти вызовы преобразуются транслятором в полные формы вызова с дополнительным параметром. В этом случае реакция транслятора однозначна: дополнительный параметр включается всякий раз, когда символы инкремента или декремента расположены справа от выражения, представляющего операнд. Значение этого параметра может вообще никак не использоваться. Оно служит исключительно для того, чтобы можно было отыскать определение соответствующей функции: CTwl.operator ++ (0); // Инкремента как функции-члена класса, operator — (CTwl, 0 ) ; // Декремента как обычной функции.

Ни одно нововведение не обходится даром. Операторные функции, которые вводятся с единственной целью сделать текст программы более ясным и легкочитаемым, могут привести к обратному результату. Работа по разборке программного кода сложна, при этом многое делается автоматически. После того, как транслятор обнаруживает выражение, которое содержит символ операторной функции, последовательность его действий может выглядеть следующим образом: 1. Производится уточнение типов его операндов; 2. Если операнды относятся к одному из базовых типов, выражение заменяется специальным фрагментом кода, реализующего соответствующую операцию; 3. Если операнды выражения имеют производный тип, транслятор рассматривает это выражение как сокращённую форму вызова операторной функции, и если подобная операторная функция действительно была определена, то сокращённый вызов заменяется полной формой вызова этой функции; 4. Если подходящей операторной функции обнаружить не удалось, транслятор пытается модифицировать операнды выражения путём приведения типа или создания временного объекта; 5. Для модифицированных операндов транслятор вновь пытается подобрать соответствующую операторную функцию; 6. В случае неудачи выдаётся сообщение об ошибке. 297"


Обилие вариантов операторных функций, в сочетании с различными конструкторами, операциями и операторными функциями приведения могут привести к самым неожиданным результатам и запутать программу. И вообще, сколько сил должен затратить программист для поддержки такого изящного и непринуждённого интерфейса. Возможно, что, больше, чем на объявление простых функций. И чем естественней система вызовов, тем тяжелее предварительная работа по объявлению соответствующего множества операторных функций. И почему бы вообще не ограничиться множеством обычных функций? Результат тот же, а работы при небольших объёмах вычислений, возможно, что и меньше. Однако при разработке больших и сложных программ операторные функции становятся всё более предпочтительными. В тех случаях, где программируется большой объём вычислений или других специальных преобразований над данными производных типов, операторные функции незаменимы. Этот раздел мы завершим перечислением ограничений на определение и использование операторных функций. Сильные средства требуют осторожного обращения. • Нельзя определить операторные функции для операндов базовых типов. Операторная функция может быть определена лишь для тех типов данных, для которых не существует обычных операций. • Невозможно изменить приоритет вновь создаваемой операторной функции, который соответствует приоритету соответствующей операции. Возможность изменения приоритета противоречила бы основному назначению операторной функции — сохранению привычного внешнего вида выражения. • Операторная функция не влияет на поведение операциианалога по отношению к основным типам данных. • C++ использует ограниченное множество знаков операций. Исключена возможность расширения этого множества за счёт введения новых символов операций, а также с помощью разнообразных комбинаций символов операций. • Невозможно также определить операторную функцию на основе новых символов и сочетаний символов операций исходного множества. Например, невозможно определить операторную функцию на основе сочетания пары символов умножения * — operator **, который можно было бы использовать для обозначения операции возведения в степень. Ш Операторные функции не могут иметь параметров со значением по умолчанию. • Операторные функции — аналоги операций =, (), -> (operator =, operator (),operator ->) не могут быть определены как глобальные функции, они должны определяться как нестатические функции-члены класса. Это связано с семантикой этих операций.

298


• Операторная функция operator=() не наследуется. Как известно, для каждого класса создаётся собственная предопределённая операторная функция. • Четыре операции не относятся к числу операторных функций: ::, ., .*, ?: . Попытки объявления операторных функций с именами, которые содержат символ одной из этих операций, заканчивается неудачей и вызывает сообщение об ошибке. • C++ не обеспечивает семантического контроля над новыми операторными функциями. Имитация предполагает лишь внешнее сходство. Операторная функция с именем operator + с одинаковым успехом может реализовать как действия, связанные со сложением соответствующих значений данных-членов объектов, так и с вычитанием, делением, конкатенацией и прочими не имеющими никакого отношения к символу операции сложения действиями.

Функции operator new() и operator deleteQ Время жизни объекта определяется областью действия его имени. В зависимости от расположения оператора определения объекта, он может располагаться в глобальном или локальном сегменте памяти. При определении глобального объекта соответствующие конструкторы объявляются со спецификатором public, поскольку они должны быть доступны фактически до начала выполнения программы. Глобальные объекты существуют в течение всего времени выполнения программы. Локальные объекты создаются в соответствующих сегментах памяти в ходе выполнения операторов определения, после передачи управления в функцию или вложенный блок операторов. По возвращении из вложенного блока или функции, имя объекта оказывается вне области действия имени. Сам же объект уничтожается в момент освобождения соответствующего сегмента памяти. Важная роль при этом отводится деструкторам. Можно избежать преждевременной гибели объекта, расположив его в динамической памяти. В этом случае память для объекта выделяется с помощью выражения размещения. Значением этого выражения является адрес области памяти, выделенной для размещения объекта в результате выполнения выражения. Очевидно, что это значение можно присвоить переменной типа указатель на объект данного класса. Динамическая память не опустошается автоматически. "Гибель" локального указателя, настроенного на выделенную область динамической памяти означает всего лишь потерю доступа к этой области памяти. В этом случае уничтожается указатель, но освобождения памяти не происходит. 299


Для освобождения памяти используется операция (операторная функция) delete. Подобно операторной функции new, delete также является статическим членом класса. В контексте выражений размещения и удаления могут быть использованы стандартные операции C++ new и delete, а может быть обеспечен вызов операторных функций operator new и operator delete. Согласно грамматике C++, основным операндом для символа операции new в выражении размещения является заключённое в круглые скобки ИмяТипа, либо l^^TnnaNew (без скобок), которое разворачивается в конструкцию, содержащую информацию о размерах размещаемого массива (константные выражения в квадратных скобках): ВыражениеРазмещения : : = [::] new [Размещение] : : = [::] new [Размещение] ИмяТипаЫеи OпиcaтeльNew

ИмяТипаЫеуг [ИнициализаторНеи./ (ИмяТипа)

СписокСпецификаторовТипа : :=

[OroicaTenbNew]

/"СписокСУОписателей; [Выражение]

При этом можно определить несколько различных вариантов операторной функции operator new. Перегруженные операторные функции будут различаться списками параметров. В C++ предусмотрены специальные средства передачи значений параметров подобным перегруженным операторным функциям. С этой целью используется так называемое Размещение, которое является необязательным составным элементом выражения размещения. Заключённый в круглые скобки список выражений располагается в выражении размещения непосредственно перед именем операторной функции new. Мы объявляем простой класс, содержащий определения операторных функций распределения динамической памяти. И размещаем это объявление в заголовочном файле с именем TypeX.h. // TypeX.h #ifndef TYPEX #define TYPEX /* Инструкции препроцессора используются для предотвращения многократного объявления класса в файле. Даже если в исходном файле появится несколько инструкций препроцессора, обеспечивающих включение заголовочного файла TypeX.h, в исходном файле окажется всего лишь одно объявление класса ТуреХ. */ // Объявление класса ТуреХ. class ТуреХ < public: /* Встроенный конструктор */ ТуреХ() { cout « "Это ТуреХО" « endl; ) /* Встроенный конструктор с параметром */ _


TypeXfint x) ( oout « "Это TypeX(" « x « " ) " « /* Встроенный деструктор */ ~TypeX() { cout « "Это -ТуреХО" « endl; )

endl; )

/* Встроенная операторная функция operator new() */ void *operator new(size_t size) { cout « "Это void «operator new(" « size « " ) " « endl; return new char(size); ) / * Операторная функция operator new () с дополнительным параметром * / void *operator new(size__t size, int xPar) < cout « "void *operator new(" « size « "," « xPar « " ) " « endl; return new char(size); /* Встроенная операторная функция operator deleted */ void operator delete(void *cPoint, size_t size) < cout « "Это void operator delete(" « size « " ) " « endl; if (cPoint) delete cPoint;

#endif Сложная семантика выражений C++ проявляется на простых примерах. Небольшие программы позволят выявить принципиальные моменты алгоритмов трансляции, свойства операций динамического распределения памяти, особенности операторных функций operator new() и operator delete(). В программе следует обратить внимание на второе выражение размещения, которое позволяет активизировать конструктор с параметрами. «include <iostream.h> «include "TypeX.h" void main!)

I

TypeX *xPoint = NULL, *xPointP = NULL, *xxPointP = NULL; xPoint = new TypeX; xPointP = new TypeX(25); // Выражение размещения может содержать параметры. // Так осуществляется управление конструктором. xxPointP = new (125+25) ТуреХ(50); // Выражение размещения может включать размещение. // Этот одноэлементный список выражений обеспечивает передачу // значений параметров операторной функции operator new. // Альтернативные формы вызова операторных функций: // ИмяТипа в круглых скобках. // xPoint = new (TypeX);

зоТ


// xPointP = new (TypeX)(25); // xxPointP = new (125+25) (TypeX) (50) ; delete xPoint; delete xPointP; delete xxPointP; cout « "OK" « endl; }

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

void *operator new(l) TypeX () void *operator new(l) TypeX(25) void *operator new(l, 150) TypeX(50) -TypeX () void operator delete(1) ~ТуреХ() void operator delete(1) ~ТуреХ() void operator delete(1)

В ходе выполнения этой программы на дисплей выводится сообщение о работе операторной функции operator new(), которая вызывается в результате определения значения выражения размещения. После этого, появляется сообщение о работе конструкторов, запуск которых обеспечивается транслятором в результате выполнения выражений размещения. Затем, непосредственно перед выполнением выражения освобождения, выполняется деструктор, о запуске которого также заботится транслятор. Наконец, управление передаётся операторной функции operator delete(). Жизненный цикл безымянных объектов, размещённых в динамической памяти в результате выполнения выражений размещения и адресуемых посредством указателей xPoint и xPointP, завершён. Недоступный и скрытый от программиста механизм запуска конструктора, достаточно сложен. В этом можно убедиться, изменив операторную функцию operator new() в классе ТуреХ следующим образом: /* Встроенная операторная функция operator new() */ void *operator new(size_t size) { cout « "Это void *operator new(" « size « " ) " « return NULL; __

endl;


Новая операторная функция даже не пытается использовать операцию выделения памяти. Она возвращает пустое значение указателя. При этом значением выражения размещения в операторе xPoint = new TypeX; оказывается нулевой адрес. В результате запуск конструктора отменяется: Это v o i d * o p e r a t o r n e w ( l ) OK

Аналогичным образом работает программный код, который обеспечивает "вызов" деструктора: непосредственно перед запуском деструктора производится проверка значения указателя. Мы возвращаем операторную функцию к исходному состоянию, после чего подвергнем исходную программу небольшой модификации. Расположим непосредственно перед символами операций new и delete (символ операции не обязательно представляет операцию!) разделители :: (именно разделители, поскольку они служат для модификации операции, а не используются в сочетании с операндами). #±nclude <iostream.h> «include "TypeX.h" void main()

I

TypeX *xPoint = NULL; xPoint = ::new TypeX; ::delete xPoint; cout « "OK" « endl; } В результате выполнения новой версии нашей программы мы получаем следующий результат: Это ТуреХО Это ~ТуреХО ОК Операторные функции не вызываются, однако память выделяется и производится запуск конструктора, а затем и деструктора. Это означает, что помеченные разделителем :: выражения размещения и освобождения исправно работают, выделяя и освобождая необходимую память. Символы операций ::new и "delete воспринимаются транслятором как символы собственных "глобальных" операций выделения и освобождения памяти языка C++. К аналогичному результату мы приходим, исключив из объявления класса ТуреХ объявления операторных функций operator new() и operator delete(). В этом случае перед символами операций new и delete даже не _ _


требуется располагать разделители. В этом случае транслятор их однозначно воспринимает как символы операций. Мы снова восстанавливаем файл с объявлением класса ТуреХ и очередной раз модифицируем нашу программу. На этот раз мы заменим выражения размещения и освобождения выражениями явного вызова операторных функций. #include <iostream.h> «include "ТуреХ.h" void main () < ТуреХ *xPoint = NULL; xPoint = (TypeX *) TypeX::operator new (sizeof(TypeX)); TypeX::operator delete(xPoint, sizeof(TypeX)); // delete xPoint; cout « "OK" « endl; }

В результате выполнения этой версии программы на дисплей будут выведены следующие сообщения: Это void *operator new(l) Это void operator delete(1) OK

Операторные функции работают, память выделяется и освобождается, однако управление конструктору и деструктору не передаётся. Выражение вызова операторных функций operator new() и operator delete() не обеспечивают "вызова" конструктора и деструктора. Мы уже знаем, что в C++, за исключением весьма странного выражения явного вызова, обращение к конструктору и деструктору обеспечивается транслятором в контексте ограниченного множества выражений. Нет соответствующего выражения, - нет и обращения к конструктору.

Размещение массивов, operator new[]() и operator delete[]() Для расположения в динамической памяти массивов, в C++ используются специальные операции и, соответственно, операторные функции. Соответствующие формы Бэкуса-Наура когда-то уже приводились ранее. Дополним объявление класса ТуреХ: class ТуреХ { void *operator new[](size_t size) { cout « "This is void *operator new[] (" «

"304

size «

")" «

endl;


return new char(size); void operator delete!](void *cPoint) { cout « "This is deleted О " « endl; if (cPoint) delete cPoint;

Ещё одна простая программа позволяет оценить работу наших новых операторных функций. Здесь следует обратить внимание на то, что выражение размещения массива активизирует лишь конструторы умолчания или конструкторы без параметров. В C++ отсутствуют синтаксические конструкции, которые позволяли бы в одном выражении размещения однозначно зафиксировать информацию, предназначенную операторной функции размещения массива operator newQ() и значения параметров для соответствующих конструкторов. Так что если в выражении размещения операндом для символа операции оказывается ИмяТипа№уу , с круглыми скобками возникают проблемы. Становится невозможно передавать информацию перегруженным конструкторам. Инициализация элементов массива в этом случае требует дополнительных специальных усилий. #include <iostream.h> «include "TypeX.h" void main() { TypeX *xPointl = NULL, *xPoint2 = NULL, *xPoint3 = NULL; // xPointl = // xPointl = // xPointl = xPointl =

new TypeX(25)[1]; new TypeX[l] (25) ; new (TypeX[l]>(25);

// Ошибка! // Ошибка! // Ошибка!

new TypeX[1];

xPoint2 = new (TypeX[2]); /* Всё правильно. Выражение в скобках транслятор воспринимает как ИмяТипа, которое состоит из ОписанногоИмениТипа (одного из вариантов СпецификатораТипа) и АбстрактногоОписателя. */ xPoint3 = ::new TypeX[3]; delete[] xPointl; deleted xPoint2; ::delete[] xPoint3; cout « "OK" « endl;

305


Вызов функции operator ~() против вызова деструктора Мы готовы к анализу контекста явного "вызова" деструктора. Рассмотрим следующий пример. На основе символа операции '-' в классе Complextype определим в самых общих чертах операторную функцию. Нам безразлично её назначение и устройство. ComplexType ComplexType::operator ~ () { cout « "Это ComplexType ComplexType::operator return ComplexType(); >

- ()" «

endl;

Ранее, в разделе, посвященном деструкторам, упоминалось, что явный "вызов" деструктора требует операций обращения. Мы предположим существование ещё одной функции-члена класса ComplexType, в теле которой и расположим интересующие нас выражения и операторы. void ComplexType::xFun() ComplexType CTw = ComplexType(); /* В результате выполнения выражения приведения управление передаётся конструктору умолчания, который создаёт временный объект, значение которого копируется в переменную CTw.*/ -CTw; CTw.operator ~ ( ) ; /•Сокращённая и полная формы вызова операторной функции ComplexType ComplexType::operator ~ ( ) * / ~ComplexType(); /* Создаётся временный безымянный объект, для которого вызывается операторная функция ComplexType ComplexType::operator ~ (). Используется сокращённая форма вызова.*/ ComplexType () .operator ~() ; /* Создаётся временный безымянный объект, для которого вызывается операторная функция ComplexType ComplexType::operator ~ (). Используется полная форма вызова.*/ CTw.-ComplexType(); /•Наконец, явный "вызов" деструктора для объекта CTw */ this->~ComplexType(); /* Явный "вызов" деструктора для объекта, расположенного по адресу this */

306


7. Шаблоны Шаблоны функций и шаблонные функции Рассмотрим простую функцию, реализующую алгоритм сравнения двух величин: int min (int iVal_l, int iVal_2) { return iVal_l < iVal_2 ? iVal_l : iVal_2;

I*

Возвращается значение iVal_l, если это значение меньше iVal_2. В противном случае возвращается значение iVal_2. */ }

Для каждого типа сравниваемых величин должен быть определён собственный вариант функции min(). Вот как эта функция выглядит для float: float min (float fVal_l, float fVal_2) { return fVal_l < fVal_2 ? fVal_l : fVal_2; )

Мы можем бесконечно упражняться в создании совместно используемых функций, хотя можно воспользоваться средствами препроцессирования: « d e f i n e min(a,b)

( ( а ) < ( Ь ) ? (а) : (Ь))

Это определение правильно работает в простых случаях: min(10, 20); min(10.0, 20.0); В более сложных случаях могут получаться неожиданные результаты, о которых уже когда-то давно мы говорили... Это происходит из-за того, что препроцессор действует независимо от компилятора, до компилятора и вообще производит лишь простую текстовую обработку исходного файла. C++ предоставляет ещё одно средство для решения этой задачи. При этом сохраняется присущая макроопределениям краткость и строгость контроля типов языка. Этим средством является шаблон функции. Шаблон функции позволяет определять семейство функций. Это семейство характеризуется общим алгоритмом, который может применяться к данным различных типов. При этом задание конкретного типа данных для очередного варианта функции обеспечивается специальной синтаксической конструкцией, называемой списком параметров шаблона

307


функции. Объявление функции, которому предшествует список параметров шаблона, называется шаблоном функции. Синтаксис объявления шаблона определяется следующим множеством предложений Бэкуса-Наура: Объявление

Обт>явлениеШаблона

ОбъявлениеШаблона : : = template <СписокПараметровШаблона> Объявление СписокПараметровШаблона ::= ПараметрШаблона ::= СписокПараметровШаблона, ПараметрШаблона ПараметрШаблона

ТиповыйПараметр *****

ТиповыйПараметр ::= class Идентификатор

Итак, объявление и определение шаблона функции начинается ключевым словом template, за которым следует заключённый в угловые скобки и разделённый запятыми непустой список параметров шаблона. Эта часть объявления или определения обычно называется заголовком шаблона. Каждый параметр шаблона состоит из служебного слова class, за которым следует идентификатор. В контексте объявления шаблона функции служебное слово class не несёт никакой особой смысловой нагрузки. Дело в том, что аналогичная конструкция используется также и для объявления шаблона класса, где, как скоро увидим, ключевое слово class играет свою особую роль. В заголовке шаблона имена параметров шаблона должны быть уникальны. Следом за заголовком шаблона располагается прототип или определение функции - всё зависит от контекста программы. Как известно, у прототипа и определения функции также имеется собственный заголовок. Этот заголовок состоит из спецификатора возвращаемого значения (вполне возможно, что спецификатором возвращаемого значения может оказаться идентификатор из списка параметров шаблона), имя функции и список параметров. Все до одного идентификаторы из заголовка шаблона обязаны входить в список параметров функции. В этом списке они играют роль спецификаторов типа. Объявления параметров, у которых в качестве спецификатора типа используется идентификатор из списка параметров шаблона, называется шаблонным параметром. Наряду с шаблонными параметрами в список параметров функции могут также входить параметры основных и производных типов. Шаблон функции служит инструкцией для транслятора. По этой инструкции транслятор может самостоятельно построить определение новой функции. Параметры шаблона в шаблонных параметрах функции обозначают места будущей подстановки, которую осуществляет транслятор в процессе __


построения функции. Область действия параметров шаблона ограничивается шаблоном. Поэтому в различных шаблонах разрешено использование одних и тех же идентификаторов-имён параметров шаблона. В качестве примера рассмотрим программу, в которой для определения минимального значения используется шаблон функции min(). template <class Type> Type min (Type a, Type b ) ; /* Прототип шаблона функции. Ключевое слово template обозначает начало списка параметров шаблона. Этот список содержит единственный идентификатор Туре. Сама функция содержит два объявления шаблонных параметра, специфицированных шаблоном параметра Туре. Спецификация возвращаемого значения также представлена шаблоном параметра Туре. */ int main (void) min(10,20);// int min (int, int); min(10.0,20.0);// float min (float, float); /* Вызовы шаблонной функции. Тип значений параметров определён. На основе выражения вызова (транслятор должен распознать тип параметров) и определения шаблона транслятор самостоятельно строит различные определения шаблонных функций. И только после этого обеспечивает передачу управления новорождённой шаблонной функции. */ return 1; template <class Type> Type min (Type a, Type b) ( return a < b ? a : b; } /* По аналогии с определением функции, эту конструкцию будем называть определением шаблона функции. */

Определение шаблона функции заставляет транслятор самостоятельно достраивать определения новых шаблонных функций, а точнее, создавать множество совместно используемых функций, у которых типы параметров и, возможно, тип возвращаемого значения зависит от типа параметров и типа возвращаемого значения в вызовах шаблонной функции. Этот процесс определения называют конкретизацией шаблона функции. В результате конкретизации шаблона функции min() транслятор строится следующий вариант программы с двумя шаблонными функциями. По выражению вызова на основе шаблона строится шаблонная функция. Почувствуйте прелесть употребления однокоренных слов! Шаблон функции и шаблонная функция — два разных понятия. int min (int a, int b ) ; float min (float a, float b ) ;

309


int main (void) min(10,20); min(10.0,20.0); return 1; int min (int a, int b) < return a < b ? a : b; } float min (float a, float b) { return a < b ? a : b;

1 Построение шаблонной функции осуществляется на основе выражений вызова. При этом в качестве значений параметров в выражении вызова могут быть использованы значения любых типов, для которых определены используемые в теле функции операции. Так, для функции min() тип параметров зависит от области определения операции сравнения <. Типы формального параметра шаблона и значения параметра выражения вызова сопоставляются без учёта каких-либо модификаторов типа. Например, если параметр шаблона в определении функции объявлен как template <class Type> Type min (Type *а, Туре *Ь) { r e t u r n а < Ь ? а : Ь;

}

и при этом вызов функции имеет вид: i n t a = 10, Ь = 20; i n t * р а = &а, *pb = &Ь; min(pa,pb); то в процессе конкретизации идентификатор типа Туре будет замещён именем производного типа int: int min (int *a, int *b) { return a < b ? a : b; } В процессе конкретизации недопустимы расширения типов и другие преобразования типов параметров: __


template <class Type> Type min (Type a, Type b) { return a < Ь ? a : b;

unsigned int a = 10;

min(1024,a); /* Здесь транслятор сообщит об ошибке. В вызове функции тип второго фактического параметра модифицирован по сравнению с типом первого параметра - int и unsigned int. Это недопустимо. В процессе построения новой функции транслятор не распознаёт модификации типов . В вызове функции типы параметров должны совпадать. Исправить ошибку можно с помощью явного приведения первого параметра. */ min((unsigned

int)1024,а);

Имя параметра шаблона в определяемой функции используется в качестве имени типа. С его помощью специализируются формальные параметры, определяется тип возвращаемого значения, определяется тип объектов, локализованных в теле функции. Имя параметра шаблона скрывает объекты с аналогичным именем в глобальной по отношению к определению шаблонной функции области видимости. Если в теле шаблонной функции необходим доступ к внешним объектам с тем же именем, следует использовать операцию изменения области видимости. И опять пример с излюбленным классом ComplexType. На множестве комплексных чисел определены лишь два отношения: равенства (предполагает одновременное равенство действительной и мнимой частей) и неравенства (предполагает все остальные случаи). В нашей новой программе мы объявим и определим шаблон функции neq(), которая будет проверять на неравенство значения различных типов. Для того, чтобы построить шаблонную функцию neq() для комплексных чисел, нам придётся дополнительно определить операторную функцию-имитатор операции != для объектов-представителей множества комплексных чисел. Это важно, поскольку операция != явным образом задействована в шаблоне neq(). Транслятор не поймёт, как трактовать символ != , а, значит, и как строить шаблонную функцию neq(ComplexType, ComplexType), если эта операторная функция не будет определена для класса ComplexType. •include

<iostream.h>

template <class Type> int neq (Type, Type); /*Прототип шаблона

функции.*/

зТТ


class ComplexType { public: double real; double imag ; // Конструктор умолчания. ComplexType(double re = 0.0, double im (real = re; imag = im;}

0.0)

/* Операторная функция != . Без неё невозможно построение шаблонной функции neq() для комплексных чисел. */ int operator != (ComplexType SKeyVal) i f (real else

K e y V a l . r e a l && imag

KeyVal.imag)

return 0; r e t u r n 1;

void main () { // Определены и проинициализированы переменные трёх типов. int i = 1, j = 2; float k = 1.0, 1 = 2.0; ComplexType CTwl(1.0,1.0), CTw2(2.0,2.0) ; // На основе выражений вызова транслятор строит // три шаблонных функции. cout « "neq() for int:" « neq(i,j) « endl; cout « "neqO for float:" « neq(k,l) « endl; cout « "neqO for ComplexType:" « neq(CTw2,CTw3) « endl;

/'Определение шаблона функции.*/ template <class Type> int neq (Type a, Type b) return a != b ? 1 : 0; // return a != b; /* На самом деле, можно и так.. */

И ещё один пример. Этот пример подтверждает обязательность включения всех параметров шаблона в список параметров шаблона определяемой функции. Независимо от того, какая роль предназначается шаблонному параметру (он вообще может не использоваться в шаблонной функции), его присутствие в списке параметров обязательно. В процессе построения шаблонной функции транслятор модифицирует весь шаблон полностью — его заголовок и его тело. Так ч