Аддитивные выражения
АддитивноеВыражение ::= МультипликативноеВыражение
::= АддитивноеВыражение + МультипликативноеВыражение
::= АддитивноеВыражение - МультипликативноеВыражение
Алфавит C++
Алфавит (или множество литер) языка программирования C++ основывается на множестве символов таблицы кодов ASCII. Алфавит C++ включает:
строчные и прописные буквы латинского алфавита (мы их будем называть буквами), цифры от 0 до 9 (назовём их буквами-цифрами), символ '_' (подчерк - также считается буквой), набор специальных символов:
" { } , | [ ] + - % / \ ; ' : ? = ! # ~ ^ . * прочие символы.
Алфавит C++ служит для построения слов, которые в C++ называются лексемами. Различают пять типов лексем:
идентификаторы, ключевые слова, знаки (символы) операций, литералы, разделители.
Почти все типы лексем (кроме ключевых слов и идентификаторов) имеют собственные правила словообразования, включая собственные подмножества алфавита.
Лексемы разделяются разделителями. Этой же цели служит множество пробельных символов, к числу которых относятся пробел, символы горизонтальной и вертикальной табуляции, символ новой строки, перевода формата и комментарии.
Базовые и производные классы
Синтаксис наследования задаётся необязательным элементом заголовка класса, который называется спецификацией базы и описывается следующим множеством форм Бэкуса-Наура:
СпецификацияБазы ::= : СписокБаз
СписокБаз ::= [СписокБаз,] ОписательБазы
ОписательБазы ::= ПолноеИмяКласса
::= [virtual] [СпецификаторДоступа] ПолноеИмяКласса
::= [СпецификаторДоступа] [virtual] ПолноеИмяКласса
Нам ещё предстоит выяснить назначение элементов описателя базы, но уже очевидно, что спецификация базы представляет собой список имён классов. Поскольку производный класс наследует данные и функции базового класса, базовые классы обязательно должны быть объявлены до объявления производного класса.
Для начала рассмотрим пример объявления нескольких классов. В этом примере задаются отношения наследования между тремя классами (классы A, B, C). При этом C наследует свойства класса B, который, в свою очередь, является наследником класса A. В этом примере все члены классов объявляются со спецификатором public, к которому мы пока относимся (пока!) как к должному. В этих классах мы объявим (просто обозначим) самые простые варианты конструкторов и деструкторов. В настоящий момент нам важно исключительно их существование.
#include iostream.h class A { public: A(){}; ~A(){}; int x0; int f0 () {return 1;}; }; class B : public A { public: B(){}; ~B(){}; int x1; int x2; int xx; int f1 () {return 100;}; int f2 () {return 200;}; }; class C : public B { public: C(){}; ~C(){}; int x1; int x2; int x3; int f1 () {return 1000;}; int f3 () {return 3000;}; }; void main () {C MyObject;}
Перед нами пример простого наследования. Каждый производный класс при объявлении наследует свойства лишь одного базового класса. В качестве базового класса можно использовать лишь полностью объявленные классы. Неполного предварительного объявления здесь недостаточно. Для наглядного представления структуры производных классов используются так называемые направленные ациклические графы. Узлы этого графа представляют классы, дуги - отношение наследования.
Вот как выглядит направленный ациклический граф ранее приведённого в качестве примера производного класса C:
A B
C
Структуру производного класса можно также представить в виде таблицы (или схемы класса), отображающей общее устройство класса:
A B C
В C++ различаются непосредственные и косвенные базовые классы. Непосредственный базовый класс упоминается в списке баз производного класса. Косвенным базовым классом для производного класса считается класс, который является базовым классом для одного из классов, упомянутых в списке баз данного производного класса.
В нашем примере для класса C непосредственным базовым классом является B, косвенным - A. Следует иметь в виду, что порядок "сцепления" классов, образующих производный класс, зависит от реализации, а потому все схемы классов и объектов имеют характер имеют чисто иллюстративный характер.
Дополним нашу схему, включив в неё объявления всех членов классов, включая, конструкторы и деструкторы.
В результате мы получаем полную схему производного класса со всеми его компонентами, вместе с его непосредственными базовыми классами, а также и косвенными базовыми классами.
A A(); ~A(); int x0; int f0 (); B B(); ~B(); int x1; int x2; int xx; int f1(); int f2(); C C(); ~C(); int x1; int x2; int xx; int f1(); int f2();
Это схема класса, а не объекта. Образно говоря, наша схема подобна схеме многоэтажного бункера, разделённого на три уровня. На схеме эти уровни разделяются двойными линиями. Класс C занимает самый нижний уровень. Именно этот класс имеет неограниченные (по крайней мере, в нашей версии объявления производного класса) возможности и полномочия доступа к элементам базовых классов. Именно с нижнего уровня можно изменять все (опять же, в нашей версии объявления класса) значения данных-членов класса и вызывать все (или почти все) функции-члены класса.
Объект-представитель класса C является единым блоком объектов и включает собственные данные-члены класса C, а также данные-члены классов B и A. Как известно, функции-члены классов, конструкторы и деструкторы не включаются в состав объекта и располагаются в памяти отдельно от объектов. Так что схему объекта-представителя класса можно представить, буквально удалив из схемы класса функции-члены, конструкторы и деструкторы.
Следует также иметь в виду, что на схеме класса располагаются лишь объявления данных-членов, тогда как схема объекта содержит обозначения определённых областей памяти, представляющих данные-члены конкретного объекта.
Итак, выполнение оператора определения C MyObj;
приводит к появлению в памяти объекта под именем MyObj. Рассмотрим схему этого объекта. Её отличие от схемы класса очевидно. Здесь мы будем использовать уже известный нам метасимвол ::= (состоит из). На схеме объекта информация о типе данного-члена будет заключаться в круглые скобки.
MyObj::= A (int)x0 B (int)x1 (int)x2 (int)xx C (int)x1 (int)x2 (int)xx
Перед нами объект сложной структуры, в буквальном смысле собранный на основе нескольких классов. В его создании принимали участие несколько конструкторов. Порядок их вызова строго регламентирован. Вначале вызываются конструкторы базовых классов. Следом вызываются конструкторы производных классов.
Благодаря реализации принципа наследования, объект представляет собой цельное сооружение. Из объекта можно вызвать функции-члены базовых объектов. Эти функции наследуются производным классом от своих прямых и косвенных базовых классов. Непосредственно от объекта возможен доступ ко всем данным-членам. Данные-члены базовых классов также наследуются производными классами.
Если переопределить деструкторы базовых и производных классов таким образом, чтобы они сообщали о начале своего выполнения, то за вызовом деструктора производного класса C непосредственно из объекта MyObj: MyObj.~C();
последует серия сообщений о выполнении деструкторов базовых классов. Разрушение производного объекта сопровождается разрушением его базовых компонентов. Причём порядок вызова деструкторов противоположен порядку вызова конструкторов.
А вот вызвать деструктор базового класса из объекта производного класса невозможно: MyObj.~B(); // Так нельзя. Это ошибка!
Частичное разрушение объекта в C++ не допускается. БАЗОВЫЕ ДЕСТРУКТОРЫ НЕ НАСЛЕДУЮТСЯ. Таков один из принципов наследования.
Если бы можно было вызывать конструктор непосредственно из объекта, аналогичное утверждение о наследовании можно было бы сделать и по поводу конструкторов.
Однако утверждение о том, что базовый конструктор не наследуется так же корректно, как и утверждение о том, что стиральная машина не выполняет фигуры высшего пилотажа. Стиральная машина в принципе не летает. НИ ОДИН КОНСТРУКТОР (ДАЖЕ КОНСТРУКТОР ПРОИЗВОДНОГО КЛАССА) НЕ ВЫЗЫВАЕТСЯ ИЗ ОБЪЕКТА.
К моменту начала разбора структуры производного класса, транслятору становятся известны основные характеристики базовых классов. Базовые классы включаются в состав производных классов в качестве составных элементов. Это означает, что в производном классе (в его функциях) можно обращаться к данным-членам и вызывать функции-члены базовых классов. Можно, если только этому ничего не мешает (о том, что может этому помешать - немного позже).
Как раз в нашем случае в этом смысле всё в порядке, и мы приступаем к модификации исходного кода нашей программы.
Прежде всего, изменим код функции с именем f1, объявленной в классе C. Мы оставим в классе лишь её объявление, а саму функцию определим вне класса, воспользовавшись при этом её квалифицированным именем.
Проблемы, связанные с одноименными членами класса решаются с помощью операции разрешения области видимости. Впрочем, нам это давно известно:
int C ::f1() { A::f0(); /*Вызов функции-члена класса A.*/ f0(); /* Для вызова этой функции можно не использовать специфицированного имени. Функция под именем f0 одна на все классы. И транслятор безошибочно определяет её принадлежность. */ A::x0 = 1; B::x0 = 2; C::x0 = 3; x0 = 4; /* К моменту разбора этой функции-члена, транслятору известна структура всех составных классов. Переменная x0 (как и функция f0) обладает уникальным именем и является общим достоянием базовых и производных классов. При обращении к ней может быть использовано как её собственное имя, так и имя с любой квалификацией. Аналогичным образом может быть также вызвана и функция f0(). */ B::f0(); C::f0(); /* Изменение значений данных-членов. */ //A::x1 = 1; /* Ошибка! Переменная x1 в классе A не объявлялась.*/ B::x1 = 2; C::x1 = 3; x1 = 4; /* Переменная x1 объявляется в двух классах. Транслятор определяет принадлежность данных-членов по квалифицированным именам. В последнем операторе присвоения транслятор считает переменную x1 собственностью класса C, поскольку этот оператор располагается "на территории" этого класса. Если бы класс C не содержал объявления переменной x1, последние три оператора были бы соотнесены с классом B. */ //A::xx = 1; /* Ошибка! Переменная xx в классе A не объявлялась.*/ B::xx = 2; C::xx = 3; xx = 4; /* Аналогичным образом обстоят дела с переменной xx, объявленной в классе B. Хотя xx не объявлялась в классе C, транслятор рассматривает эту переменную как элемент этого класса и не возражает против квалифицированного имени C::xx. В последнем операторе транслятор рассматривает переменную xx как член класса B. */ return 150; } Теперь переопределим функцию-член класса B. При её разборе (даже если определение этой функции располагается после объявления класса C), транслятор воспринимает лишь имена базовых классов. В это время транслятор забывает о существовании класса C. А потому упоминание этого имени воспринимается им как ошибка. int B ::f1() { A::f0(); A::x0 = 1; B::x0 = 2; //C::x0 = 3; /* Ошибка. */ x0 = 4; B::f0(); //C::f0(); /* Ошибка. */ /* Изменение значений данных-членов. */ //A::x1 = 1; /* Ошибка. Переменная x1 в классе A не объявлялась.*/ B::x1 = 2; //C::x1 = 3; /* Ошибка. */ x1 = 4; //A::xx = 1; /* Ошибка! Переменная xx в классе A не объявлялась.*/ B::xx = 2; //C::xx = 3; /* Ошибка. */ xx = 4; return 100; }
Нам остаётся рассмотреть, каким образом транслятор соотносит члены класса непосредственно в объекте. Для этого переопределим функцию main():
void main () { C MyObj; MyObj.x0 = 0; MyObj.B::x0 = 1; MyObj.C::x0 = 2; MyObj.f0(); MyObj.A::f0(); MyObj.C::f0(); /* Поиск "снизу-вверх" является для транслятора обычным делом. Транслятор способен отыскать нужные функции и данные даже у косвенного базового класса. Главное, чтобы они были там объявлены. И при было бы возможным однозначное соотнесение класса и его члена. */ MyObj.x1 = 777; MyObj.B::x1 = 999; cout MyObj.A::x1 "-" MyObj.B::x1; /* Процесс соотнесения осуществляется от потомков к предкам. Не специфицированный член класса x1 считается членом "ближайшего" производного класса, о чём и свидетельствует последняя тройка операторов. */ MyObj.B::f2(); MyObj.C::f2(); /* И опять успешное соотнесение благодаря поиску "снизу-вверх". Недостающие элементы в производном классе можно поискать по базовым классам. Важно, чтобы они там были. */ // MyObj.A::f1(); // MyObj.A::f2(); // MyObj.A::f3(); // MyObj.B::f3(); /* А вот "сверху вниз" транслятор смотреть не может. Предки не отвечают за потомков. */ }
Таким образом, корректное обращение к членам класса в программе обеспечивается операцией разрешения области видимости. Квалифицированное имя задаёт область действия имени (класс), в котором начинается (!) поиск данного члена класса. Принципы поиска понятны из ранее приведённого примера.
Библиотеки
Языки программирования предназначены для написания программ. Однако было бы странно писать всякий раз одни и те же программы или даже одни и те же подпрограммы (например, подпрограмму вывода информации на дисплей или на принтер - эта подпрограмма требуется практически в каждой программе).
К счастью, проблема многократного использования программного кода уже очень давно и успешно решена.
Практически каждая система, реализующая тот или иной язык программирования (транслятор, компоновщик и прочее программное окружение) имеет набор готовых к использованию фрагментов программного кода. Этот код может находиться в разной степени готовности. Это могут быть фрагменты текстов программ, но, как правило, это объектный код, располагаемый в особых файлах. Такие файлы называются библиотечными файлами.
Для использования библиотечного кода программисту бывает достаточно указать в программе требуемый файл и обеспечить вызов соответствующих функций. Для использования библиотечного кода бывает достаточно стандартного набора языковых средств. Решение всех остальных проблем транслятор и компоновщик берут на себя. Разумеется, программисту должно быть известно о существовании подобных библиотек и о содержании библиотечных файлов.
Битовые поля
Битовое поле - это последовательность битов. Минимальная длина битового поля, естественно, равняется 1 (одному биту), максимальная длина зависит от реализации. Битовое поле длинной в восемь бит - не байт. Байт - это минимальная адресуемая область памяти ЭВМ, битовое поле - языковая конструкция. Среди форм Бэкуса-Наура, посвящённых объявлению класса, напомним соответствующую БНФ: ОписательЧленаКласса ::= [Идентификатор] : КонстантноеВыражение
Вот такой описатель члена класса и задаёт битовое поле. Битовое поле может существовать исключительно как элемент класса. Идентификатор (необязательный!) задаёт имя поля, константное выражение - размеры этого поля в битах. Согласно ранее приведённым БНФ, подобному описателю должны предшествовать спецификаторы объявления. Как известно, они специфицируют тип объявляемого члена класса.
В C++ существует ограничения на тип битового поля. Это всегда целочисленный тип. Вполне возможно, что тип знаковый. По крайней мере, в Borland C++, максимально допустимый размер поля равняется длине (количеству бит), объекта соответствующего типа.
Рассмотрим пример объявления битового поля:
ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::= СпецификаторОбъявления ОписательЧленаКласса; ::=
int [Идентификатор] : КонстантноеВыражение; ::= int MyField:5; А вот как объявления битовых полей выглядят в контексте объявления класса: struct BitsFields { int IntField : 1; char CharField : 3; int : 3 unsigned UnsignedField : 1; };
Неименованное битовое поле также является членом класса. Существует множество ситуаций, в которых оправдано использование неименованных битовых полей. В конце концов, они ничем не хуже неименованных параметров функций. Неименованные поля могут использоваться для заполнения соответствующей области памяти. Если это поле является полем нулевой длины (для неименованного поля возможно и такое), оно может задавать выравнивание следующего битового поля по границе очередного элемента памяти. Хотя и здесь, в конечном счёте, многое зависит от реализации.
К неименованным битовым полям нельзя обратиться по имени, их невозможно инициализировать и также невозможно прочитать их значение путём непосредственного обращения к битовому полю.
Именованные битовые поля инициализируются подобно обычным переменным. И значения им можно присвоить любые (разумеется, в пределах допустимого для данного типа диапазона значений).
BitsFields QWE; ::::: QWE.CharField = 100; QWE.IntField = 101; QWE.UnsignedField = 1;
Но фактически значения в битовом поле ограничиваются размерами битового поля. Было поле объявлено размером в три бита - диапазон его значений и будет ограничен этими самыми тремя битами:
cout QWE.CharField "....." endl; cout QWE.IntField "....." endl; cout QWE.UnsignedField "....." endl; :::::
В Borland C++ у битового поля знакового типа, независимо от размеров этого поля, один из битов остаётся знаковым. В результате, однобитовое знаковое поле способно принимать только одно из двух значений: либо -1, либо 0.
В ряде книг утверждается, что битовые поля способствуют "рациональному использованию памяти". В "Справочном руководстве по 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;
После препроцессирования оно принимает такой вид: float pi = 3.14159;
Сначала препроцессор замещает, затем транслятор транслирует. И потому здесь будет зафиксирована ошибка: #define PI 3.14 0.00159 float pi = PI;
После препроцессирования объявление принимает такой вид: float pi = 3.14 0.00159;
А здесь - всё корректно: #define PI 3.14 + 0.00159 float pi = PI;
После препроцессирования получается правильное объявление с инициализацией: float pi = 3.14 + 0.00159;
Строка замещения может оказаться пустой. #define ZZZ
В этом случае оператор-выражение ZZZ;
и ещё более странные конструкции ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ ZZZ;
превращаются препроцессором в пустой оператор. Это лишь побочный эффект работы препроцессора. У макроопределений с пустой строкой замещения имеется собственная область пременения.
Строка замещения может располагаться на нескольких строках. При этом символ '\' уведомляет препроцессор о необходимости включения в состав строки замещения текста, располагаемого на следующей стоке. Признаком завершения многострочного определения является символ конца строки: #define TEXT "1234567890-=\ йцукенгшщзхъ\"
В ходе препроцессорной обработки вхождения идентификатора TEXT заменяются на строку замещения: 1234567890-= йцукенгшщзхъ\
Макроопределения define могут быть вложенными: #include iostream.h #define WHISKEY "ВИСКИ с содовой." #define MARTINI "МАРТИНИ со льдом и " WHISKEY void main() {cout MARTINI;}
В результате выполнения последнего оператора выводится строка МАРТИНИ со льдом и ВИСКИ с содовой.
После каждого расширения препроцессор возвращается к началу файла, переходит к очередному макроопределению и повторяет процесс преобразования макроопределений. Препроцессорные замены не выполняются внутри строк, символьных констант и комментариев. При этом в замещающей части не должно быть вхождений замещаемой части макроопределения.
Так что макроопределение #define WHISKEY "стаканчик ВИСКИ " WHISKEY
обречено на неудачу.
В макроопределениях может встречаться несколько макроопределений с одной и той же замещаемой частью. При этом следует использовать в тексте программы директиву препроцессора #undef ИмяЗамещаемойЧасти
Эта инструкция прекращает действие препроцессора по замене соответствующего идентификатора. #define PI 3.14 + 0.00159 float pi1 = PI; #undef PI #define PI 3.14159 float pi2 = PI;
Друзья класса
Три спецификатора доступа обеспечивают в C++ управление доступом. Эти спецификаторы являются основанием принципа инкапсуляции - одного из трёх основных принципов объектно-ориентированного программирования. Соблюдение правил доступа повышает надёжность программного обеспечения.
Спецификаторы доступа способны обеспечить многоуровневую защиту функций и данных в наследуемых классах. Порождаемые на основе "инкапсулированных" классов объекты способны поддерживать жёсткий интерфейс. Они подобны "чёрным" ящикам с чётко обозначенными входами и выходами. Вместе с тем, следует признать, что система управления доступом, реализованная на основе трёх спецификаторов, не является гибкой. С её помощью может быть реализована защита по принципу "допускать ВСЕХ (члены класса, объявленные в секции public) или не допускать НИКОГО (члены класса, объявленные в секциях protected и private)". В C++ существует возможность организации более гибкой защиты. Здесь можно также объявлять функции, отдельные функции-члены классов и даже классы (в этом случае речь идёт о полном множестве функций-членов класса), которые получают доступ к защищённым и приватным членам данного класса. Что означает реализацию системы управления доступом принципу "не допускать НИКОГО, КРОМЕ". Такие функции и классы называют дружественными функциями и классами. Объявление дружественных классов и функций включается в объявление данного класса вместе со спецификатором объявления friend. Здесь нам потребуется всего одна форма Бэкуса-Наура для того, чтобы дополнить синтаксис объявления.
СпецификаторОбъявления ::= friend ::= *****
Рассмотрим небольшой пример использования дружественных функций и классов, а затем сформулируем основные правила работы с друзьями классов. В программе объявлены два класса, один из которых является другом другого класса и всеобщая дружественная функция.
#include iostream.h class XXX; /* Неполное объявление класса. Оно необходимо для объявления типа параметра функции-члена для следующего класса. */ class MMM { private: int m1; public: MMM(int val); void TypeVal(char *ObjectName, XXX ClassParam); }; MMM::MMM(int val) { m1 = val; } /* Определение функции-члена TypeVal располагается после объявления класса XXX. Только тогда транслятор узнаёт о структуре класса, к которому должна получить доступ функция MMM::TypeVal. */ class XXX { friend class YYY; friend void MMM::TypeVal(char *ObjectName, XXX ClassParam); friend void TypeVal(XXX ClassParamX, YYY ClassParamY); /* В классе объявляются три друга данного класса: класс YYY, функция-член класса MMM, простая функция TypeVal. В класс XXX включаются лишь объявления дружественных функций и классов. Все определения располагаются в других местах - там, где им и положено быть - в своих собственных областях видимости. */ private: int x1; public: XXX(int val); }; XXX::XXX(int val) { x1 = val; } void MMM::TypeVal(char *ObjectName, XXX ClassParam) { cout "Значение " ObjectName ": " ClassParam.x1 endl; } /* Отложенное определение функции-члена MMM::TypeVal. */ class YYY { friend void TypeVal(XXX ClassParamX, YYY ClassParamY); private: int y1; public: YYY(int val); void TypeVal(char *ObjectName, XXX ClassParam); }; YYY::YYY(int val) { y1 = val; } void YYY::TypeVal(char *ObjectName, XXX ClassParam) { cout "Значение " ObjectName ": " ClassParam.x1 endl; } void TypeVal(XXX ClassParamX, YYY ClassParamY); void main() { XXX mem1(1); XXX mem2(2); XXX mem3(3); YYY disp1(1); YYY disp2(2); MMM special(0); disp1.TypeVal("mem1", mem1); disp2.TypeVal("mem2", mem2); disp2.TypeVal("mem3", mem3); special.TypeVal("\n mem2 from special spy:", mem2); TypeVal(mem1, disp2); TypeVal(mem2, disp1); } void TypeVal(XXX ClassParamX, YYY ClassParamY) { cout endl; cout "???.x1 == " ClassParamX.x1 endl; cout "???.y1 == " ClassParamY.y1 endl; }
В этом примере все функции имеют одинаковые имена. Это не страшно. Это даже полезно, поскольку становится очевидным факт существования разных областей действия имён.
В заключение раздела перечислим основные правила пользования новыми средствами управления доступа - дружественной системой защиты.
Друзья класса не являются членами класса. Они должны определяться вне класса, для которого они объявляются друзьями, а об особых отношениях между ними и данным классом свидетельствует лишь специальное объявление(!) со спецификатором объявления friend. Объявления дружественного класса означает, что в дружественном классе доступны все компоненты объявляемого класса. Дружественные данному классу функции не являются членами этого класса. Поэтому они не могут быть вызваны из объекта-представителя класса, для которого была объявлена другом данная функция, при помощи операции доступа к члену класса. Дружественная функция может быть функцией-членом другого ранее объявленного класса. Правда, при этом само определение дружественной функции приходится располагать после объявления класса, другом которого была объявлена данная функция. Это не очень удобно и красиво, но зато работает. Дружественная функция не имеет this указателя для работы с классом, содержащим её объявление в качестве дружественной функции. Дружба - это всего лишь дополнение принципа инкапсуляции и ничего более. Дружественные отношения не наследуются. Дружественные функции не имеют доступа к членам производного класса, чьи базовые классы содержали объявления этих функций. Дети не отвечают за отношения своих родителей.
Функции-члены: прототипы и определения
При трансляции объявления класса и иножества обычных функций транслятор использует различные методы. Следующий пример подтверждает это:
// функции-члены класса объявлены без прототипов. class xClass { void f1() {f2();} // Функция-член f1 содержит вызов ещё неизвестной функции f2. void f2() { } }; // Следующие функции также объявляются без прототипов. void f1() {f2();} // Здесь будет зафиксирована ошибка. // Транслятор ничего не знает о функции f2(). void f2() { } void main() {f1();}
Определяемая непосредственно в теле класса функция-член класса оказывается без прототипа. За счёт дополнительного прохода по объявлению класса, транслятор самостоятельно строит прототип такой функции. При этом определение встроенной функции преобразуется к определению обычной функции-члена с квалифицированным именем и располагаемой вне объявления класса. В результате в классе всё равно не остаётся ни одного определения функции. Все они оказываются за пределами тела класса. Непосредственно в классе остаются лишь прототипы. Построение прототипа функции-члена по её определению при условии нескольких проходов по объявлению класса не самая сложная задача для транслятора. И только после этого, на основе восстановленного списка прототипов функций-членов транслятор приступает к разбору самих функций.
Новые алгоритмы разбора порождают дополнительные ограничения на структуру объявления класса. Прототип функции не может располагаться в теле класса вместе с определением функции. Из-за этого в классе не допускается более одного прототипа для каждой функции-члена. Однако допускается поэтапная инициализация параметров: часть из них можно проинициализировать в прототипе, часть непосредственно при определении функции:
class QQQ { //int www(int, int); int www(int, int = 0); }; int QQQ::www(int key1 = 100, int key2){ return key2;}
Функции operator new() и operator delete()
Время жизни объекта определяется областью действия его имени. В зависимости от расположения оператора определения объекта, он может располагаться в глобальном или локальном сегменте памяти. При определении глобального объекта соответствующие конструкторы объявляются со спецификатором public, поскольку они должны быть доступны фактически до начала выполнения программы. Глобальные объекты существуют в течение всего времени выполнения программы.
Локальные объекты создаются в соответствующих сегментах памяти в ходе выполнения операторов определения, после передачи управления в функцию или вложенный блок операторов. По возвращении из вложенного блока или функции, имя объекта оказывается вне области действия имени. Сам же объект уничтожается в момент освобождения соответствующего сегмента памяти. Важная роль при этом отводится деструкторам.
Можно избежать преждевременной гибели объекта, расположив его в динамической памяти. В этом случае память для объекта выделяется с помощью выражения размещения. Значением этого выражения является адрес области памяти, выделенной для размещения объекта в результате выполнения выражения. Очевидно, что это значение можно присвоить переменной типа указатель на объект данного класса.
Динамическая память не опустошается автоматически. "Гибель" локального указателя, настроенного на выделенную область динамической памяти означает всего лишь потерю доступа к этой области памяти. В этом случае уничтожается указатель, но освобождения памяти не происходит.
Для освобождения памяти используется операция (операторная функция) delete. Подобно операторной функции new, delete также является статическим членом класса.
В контексте выражений размещения и удаления могут быть использованы стандартные операции C++ new и delete, а может быть обеспечен вызов операторных функций operator new и operator delete.
Согласно грамматике C++, основным операндом для символа операции new в выражении размещения является заключённое в круглые скобки ИмяТипа, либо ИмяТипаNew (без скобок), которое разворачивается в конструкцию, содержащую информацию о размерах размещаемого массива (константные выражения в квадратных скобках):
ВыражениеРазмещения
::= [::] new [Размещение] ИмяТипаNew [ИнициализаторNew] ::= [::] new [Размещение] (ИмяТипа) [ИнициализаторNew]
ИмяТипаNew ::= СписокСпецификаторовТипа [ОписательNew]
ОписательNew ::= [СписокCVОписателей] [ОписательNew] ::= [ОписательNew] [Выражение] ::= *****
При этом можно определить несколько различных вариантов операторной функции operator new. Перегруженные операторные функции будут различаться списками параметров. В C++ предусмотрены специальные средства передачи значений параметров подобным перегруженным операторным функциям. С этой целью используется так называемое Размещение, которое является необязательным составным элементом выражения размещения. Заключённый в круглые скобки список выражений располагается в выражении размещения непосредственно перед именем операторной функции new.
Мы объявляем простой класс, содержащий определения операторных функций распределения динамической памяти. И размещаем это объявление в заголовочном файле с именем TypeX.h.
// TypeX.h #ifndef TYPEX #define TYPEX /* Инструкции препроцессора используются для предотвращения многократного объявления класса в программном модуле. Даже если в исходном файле появится несколько инструкций препроцессора, обеспечивающих включение заголовочного файла TypeX.h, в исходном файле окажется всего лишь одно объявление класса TypeX. */ // Объявление класса TypeX. class TypeX { public: /* Встроенный конструктор */ TypeX() { cout "Это TypeX()" endl; } /* Встроенный конструктор с параметром */ TypeX(int x) { cout "Это TypeX(" x ")" endl; } /* Встроенный деструктор */ ~TypeX() { cout "Это ~TypeX()" 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 delete() */ 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() { TypeX *xPoint = NULL, *xPointP = NULL, *xxPointP = NULL; xPoint = new TypeX; xPointP = new TypeX(25); // Выражение размещения может содержать параметры. // Так осуществляется управление конструктором. xxPointP = new (125+25) TypeX(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; }
В ходе трансляции распознаются выражения размещения и освобождения, и делается всё необходимое для своевременного вызова конструкторов и деструктора. Если к тому же, в объявлении класса обнаружены объявления соответствующих операторных функций, эти выражения преобразуются транслятором в вызовы операторных функций.
Так что транслируем, запускаем и наблюдаем результаты:
Это void *operator new(1) Это TypeX() Это void *operator new(1) Это TypeX(25) Это void *operator new(1, 150) Это TypeX(50) Это ~TypeX() Это void operator delete(1) Это ~TypeX() Это void operator delete(1) Это ~TypeX() Это void operator delete(1) OK
В ходе выполнения этой программы на дисплей выводится сообщение о работе операторной функции operator new(), которая вызывается в результате определения значения выражения размещения.
После этого, появляется сообщение о работе конструкторов, запуск которых обеспечивается транслятором в результате выполнения выражений размещения.
Затем, непосредственно перед выполнением выражения освобождения, выполняется деструктор, о запуске которого также заботится транслятор.
Наконец, управление передаётся операторной функции operator delete(). Жизненный цикл безымянных объектов, размещённых в динамической памяти в результате выполнения выражений размещения и адресуемых посредством указателей xPoint и xPointP, завершён.
Недоступный и скрытый от программиста механизм запуска конструктора, достаточно сложен. В этом можно убедиться, изменив операторную функцию operator new() в классе TypeX следующим образом:
/* Встроенная операторная функция operator new() */ void *operator new(size_t size) { cout "Это void *operator new(" size ")" endl; return NULL; }
Новая операторная функция даже не пытается использовать операцию выделения памяти. Она возвращает пустое значение указателя. При этом значением выражения размещения в операторе xPoint = new TypeX;
оказывается нулевой адрес. И в результате запуск конструктора отменяется:
Это void *operator new(1) OK
Аналогичным образом работает программный код, который обеспечивает вызов деструктора: непосредственно перед запуском деструктора производится проверка значения указателя.
Мы возвращаем операторную функцию к исходному состоянию, после чего подвергнем исходную программу небольшой модификации. Расположим непосредственно перед символами операций new и delete (символ операции не обязательно представляет операцию!) разделители :: (именно разделители, поскольку они служат для модификации операции, а не используются в сочетании с операндами).
#include iostream.h #include "TypeX.h" void main() { TypeX *xPoint = NULL; xPoint = ::new TypeX; ::delete xPoint; cout "OK" endl; }
В результате выполнения новой версии нашей программы мы получаем следующий результат:
Это TypeX() Это ~TypeX() OK
Операторные функции не вызываются, однако память выделяется и производится запуск конструктора, а затем и деструктора.
Это означает, что помеченные разделителем :: выражения размещения и освобождения исправно работают, выделяя и освобождая необходимую память. Символы операций ::new и ::delete воспринимаются транслятором как символы собственных "глобальных" операций выделения и освобождения памяти языка C++.
К аналогичному результату мы приходим, исключив из объявления класса TypeX объявления операторных функций operator new() и operator delete(). В этом случае перед символами операций new и delete даже не требуется располагать разделители. В этом случае транслятор их однозначно воспринимает как символы операций.
Мы снова восстанавливаем файл с объявлением класса TypeX и очередной раз модифицируем нашу программу. На этот раз мы заменим выражения размещения и освобождения выражениями явного вызова операторных функций.
#include iostream.h #include "TypeX.h" void main() { TypeX *xPoint = NULL; xPoint = (TypeX *) TypeX::operator new (sizeof(TypeX)); TypeX::operator delete(xPoint, sizeof(TypeX)); // delete xPoint; cout "OK" endl; }
В результате выполнения этой версии программы на дисплей будут выведены следующие сообщения:
Это void *operator new(1) Это void operator delete(1) OK
Операторные функции работают успешно, память выделяется и освобождается, однако управление конструктору и деструктору не передаётся. Выражение вызова операторных функций operator new() и operator delete() не обеспечивают вызова конструктора и деструктора. Мы уже знаем, что в C++, за исключением весьма странного выражения явного вызова, вызов конструктора и деструктора обеспечивается транслятором в контексте ограниченного множества выражений. Нет соответствующего выражения, - нет и вызова конструктора.
Функции с изменяемым списком параметров
Для решения задачи передачи неопределённого количества параметров C++ располагает также средствами объявления переменных списков параметров.
Вспомним несколько форм Бэкуса-Наура, определяющих синтаксис списка параметров в определении и прототипе функции. СписокОбъявленийПараметров ::= [СписокОбъявленийПарам] [...] ::= СписокОбъявленийПарам, ... СписокОбъявленийПарам ::= ОбъявлениеПараметра
::= [СписокОбъявленийПарам,] ОбъявлениеПараметра
Таким образом, список объявлений параметров может завершаться многоточием, отделённым запятой от списка объявлений параметров, этого многоточия в списке параметров может не быть, а возможно также, что кроме многоточия в списке параметров вовсе ничего нет.
Так вот это многоточие предупреждает транслятор о том, что определяемая или объявляемая функция может вызываться с произвольным списком параметров.
В этом случае количество и тип параметров становятся известны из списка выражений, определяющих значения параметров в выражении вызова функции.
Рассмотрим прототип и определение функции с переменным количеством параметров. int PP(…); int PP(…) { return 100; }
Трансляция этого фрагмента кода не вызывает у транслятора никаких возражений. Многоточием в списке параметров он предупреждён о возможных неожиданностях.
Следующий фрагмент кода демонстрирует варианты выражений вызова функции PP(). int retVal; retVal = PP(); retVal = PP(1,2 + retVal,3,4,5,25*2); PP('z',25,17);
В ходе выполнения выражений вызова функций с переменным количеством параметров изменяется алгоритм формирования записи активации. Теперь он выглядит примерно так:
в стековом сегменте выделяется запись активации. Теперь размер записи активации зависит от количества и типа параметров в выражении вызова, (а не прототипа). Так что сначала нужно определить и запомнить общее количество и тип выражений, которые образуют список параметров в выражении вызова функции. Как и раньше, вычисление значений производится в процессе выполнения программы. Как и раньше, значение параметра может быть представлено любыми выражениями; на основе анализа прототипов вызываемой функции, расположенных в области видимости вызывающей функции, определяются начальные значения параметров (если они имеются), которые и записываются в соответствующие области записи активации. Как мы увидим дальше, в функциях с переменным количеством параметров слева от многоточия всё же может находиться хотя бы один явным образом определённый параметр. В противном случае просто не будет возможности воспользоваться значениями параметров; вычисляются значения выражений, которые образуют список параметров в выражении вызова. Поскольку вычисление значений производится в ходе выполнения программы, здесь также нет никаких ограничений на процесс определения значения выражений. Можно использовать любые значения, а также вызовы ранее объявленных функций; элементам записи активации присваиваются вычисленные значения. При этом возможно, часть параметров, которым были присвоены значения умолчания (это всегда ближайшие к многоточию параметры), получит новые значения. В этом процессе не допускается неопределённых ситуаций. Либо элемент записи активации получает значение умолчания, либо ему присваивается значение при вызове. Нарушение порядка означивания, как и раньше, выявляется ещё на стадии трансляции программы; в вызываемой функции всем параметрам, которые были указаны в списке параметров, присваиваются значения из записи активации. Для остальных (непостоянных и, естественно, безымянных) параметров выделяется дополнительная память. Эти параметры также получают свои значения из записи активации; управление передаётся первому оператору функции. Означенные параметры используются как переменные с определёнными значениями. Доступ к безымянным параметрам, в силу того, что к ним невозможно обращение по имени, обеспечивается специальными алгоритмами.
Итак, параметрам вызываемой функции присвоены соответствующие значения, представленные в выражении вызова. Возникает вопрос, как воспользоваться этими значениями в теле вызываемой функции. Если у параметра существует собственное имя, то всё очевидно.
Если же параметр был определён как параметр без имени, то существует единственный способ доступа к таким параметрам - доступ с помощью указателей.
Дело в том, что все означенные параметры, с именами и безмянные, занимают одну непрерывную область памяти. Поэтому для доступа к элементам этого списка достаточно знать имя и тип хотя бы одного параметра. Для этого в функции определяется указатель, которому с помощью операции взятия адреса присваивается значение, которое соответствует адресу именованного параметра. Переход от параметра к параметру при этом обеспечивается с помощью операций адресной арифметики над значением этого указателя.
С точки зрения реализации всё очень просто. Если бы не одно обстоятельство, которое заметно ограничивает свободу применения подобных функций.
Дело в том, что всякий раз при создании функций с неопределённым количеством параметров, мы вынуждены разрабатывать алгоритм доступа к списку этих самых параметров. А для этого необходимо, по крайней мере, представлять закономерность расположения параметров в списке. Так что список необъявленных параметров не может состоять из подобранных случайным образом элементов, поскольку не существует универсальных средств распознавания элементов этого списка. На практике дело обычно ограничивается несколькими тривиальными вариантами.
При этом либо известен тип и количество передаваемых параметров, и процедура доступа к параметрам сводится к примитивному алгоритму, который воспроизводится в следующем примере: #include iostream.h long PP(int n, ...); void main (void) { long RR; RR = PP(5, 1, 2, 3, 4, 5 ); /* Вызвали функцию с 6 параметрами. Единственный обязательный параметр определяет количество передаваемых параметров. */ cout RR endl; } long PP(int n ...) { int *pPointer = n; // Настроились на область памяти с параметрами... int Sum = 0; for ( ; n; n--) Sum += *(++pPointer); return Sum; }
Либо известен тип элементов списка и признак завершения списка передаваемых параметров. Процедура доступа к параметрам также проста, как и в первом случае: #include iostream.h long PP(int par1 ...); void main (void) { long RR; RR = PP( 1, 2, 0, 4, 0 ); /* Вызвали функцию с 5 параметрами. Единственный обязательный параметр - первый параметр в списке параметров. */ cout RRR endl; } long PP(int par1 ...) { int *pPointer = par1; /* Настроились на область памяти с параметрами. Признак конца списка - параметр с нулевым значением. */ int Sum = 0; for ( ; *pPointer != 0; pPointer++) Sum += *pPointer; // Что-то здесь не так… Мы так и не обработали до конца весь список. return Sum; }
Функция. Прототип
Функция в 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 (void) { ZZ(125); } Файл zz.cpp: void ZZ(int par1) { cout "This is ZZ " par1 endl; }
Но как только в исходном файле возникает ситуация, при которой вызов функции появляются в тексте программы до определения функции, разбор выражения вызова завершается ошибкой: #include iostream.h void main (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) { ZZ(125); } void ZZ(int par1) { cout "This is ZZ " par1 endl; }
Самое интересное, что и такое объявление не вызывает возражений транслятора. #include iostream.h void ZZ(int); /* Отсутствует имя параметра. Можно предположить, что имя параметра не является обязательным условием правильной компиляции. */ void main (void) { ZZ(125); } void ZZ(int par1) { cout "This is ZZ " par1 endl; }
Правила грамматики подтверждают это предположение. Ранее соответствующее множество БНФ уже рассматривалось: ОбъявлениеПараметра ::= СписокСпецификаторовОбъявления Описатель
::= СписокСпецификаторовОбъявления
Описатель
Инициализатор
::= СписокСпецификаторовОбъявления
[АбстрактныйОписатель] [Инициализатор]
Из этой формы Бэкуса-Наура следует, что объявление параметра может состоять из одного спецификатора объявления (частный случай списка спецификаторов). Так что имени параметра в списке объявления параметров в прототипе функции отводится в букальном смысле роль украшения. Его основное назначение в прототипе - обеспечение легкочитаемости текста программы. Принципиальное значение имеет соответствие типов параметров в определении и объявлении функции.
Попытка трансляции следующего примера программы оказывается неудачной. #include iostream.h void ZZ(float);// Другой тип параметра. void main (void) { ZZ(125); } void ZZ(int par1) { cout "This is ZZ " par1 endl; }
Если функция не возвращает значения, в объявлении и определении обязательно используется спецификатор объявления void.
Функция также может не иметь параметров. В этом случае объявление параметров в определении и прототипе может быть либо пустым, либо может состоять из одного ключевого слова void. В контексте объявления параметров слово void и пустой список спецификаторов параметров эквивалентны.
Инициализация объекта: параметры и инициализаторы
Совместно используемые функции различаются списками параметров. В этом смысле конструкторы подобны функциям. Рассмотрим определение конструктора с параметрами. Мы расположим его за пределами класса. При этом в классе располагается прототип конструктора, а его имя при определении заменяется квалифицированным именем:
class ComplexType { ::::: public: ComplexType(double keyReal, double keyImag, char keyCTcharVal, int keyX); ::::: }; ::::: ComplexType::ComplexType(double keyReal, double keyImag, char keyCTcharVal, int keyX) { cout "This is ComplexType(" keyReal "," keyImag "," (int)keyCTcharVal "," keyX ")" endl; real = keyReal; imag = keyImag; CTcharVal = keyCTcharVal; x = keyX; };
А вот и подходящее определение. Мы расположим его в функции main:
ComplexType CDw2(100,100,0,0); /* Создаётся объект типа ComplexType под именем CDw2 с определёнными значениями. */ int iVal(10); /* Аналогичным образом может быть определён и проинициализирован объект основного типа */
Заметим, что к такому же результату (но только окольными путями) приводит и такая форма оператора определения: ComplexType CDw2 = ComplexType(100,100,0,0);
И снова мы встречаем случай определения объекта посредством постфиксного выражения. Здесь опять можно говорить о явном обращении к конструктору с передачей ему параметров. Выражения явного приведения типа здесь построить невозможно, поскольку за заключённым в скобочки именем типа должно стоять унарное выражение.
Заметим, что не может быть операторов определения переменных с пустым списком инициализаторов:
ComplexType CDw1(); // Это ошибка! int xVal(); // Это тоже не определение.
Независимо от типа определяемой переменной, подобные операторы воспринимаются транслятором как прототипы функций с пустым списком параметров, возвращающие значения соответствующего типа.
При объявлении и определении функций C++ позволяет производить инициализацию параметров. Аналогичным образом может быть модифицирован прототип конструктора с параметрами:
ComplexType(double keyReal = 0, double keyImag = 0, char keyCTcharVal = 0, int keyX = 0);
Но при этом программист должен быть готовым к самым неожиданным ситуациям. Последняя модификация прототипа вызывает протест со стороны транслятора. Он не может теперь однозначно соотнести оператор определения объекта с одним из вариантов конструктора. Перед нами тривиальный случай проявления проблемы сопоставления. Мы закомментируем определение самого первого конструктора (конструктора без параметров) и опять всё будет хорошо. Теперь вся работа по определению и инициализации объектов обеспечивается единственным конструктором с проинициализированными параметрами.
Конструктор, управление которому передаётся в результате выполнения оператора определения без параметров, называется конструктором умолчания. К конструкторам умолчания относятся следующие конструкторы:
конструктор, автоматически создаваемый транслятором, определяемый программистом конструктор с пустым списком параметров, конструктор с проинициализированными по умолчанию параметрами.
Внесём ещё одно изменение в текст нашей программы. На этот раз мы добавим спецификатор const в объявление данного-члена класса x:
class ComplexType { ::::: const int x; ::::: }
И опять возникают новые проблемы. На этот раз они связаны с попыткой присвоения значения константе. Как известно, объявление данного-члена класса не допускает инициализации, а для того, чтобы константный член класса в процессе создания объекта всё же мог получить требуемое значение, в C++ используется так называемый ctorИнициализатор (именно так называется эта конструкция в справочном руководстве по C++ Б.Строуструппа). Мы не будем гадать, в чём заключается смысл этого названия, а лучше заново воспроизведем несколько форм Бэкуса-Наура.
ОпределениеФункции ::= [СписокСпецификаторовОбъявления] Описатель
[ctorИнициализатор] ТелоФункции
ctorИнициализатор ::= : СписокИнициализаторовЧленовКласса
СписокИнициализаторовЧленовКласса ::= ИнициализаторЧленаКласса
[, СписокИнициализаторовЧленовКласса]
ИнициализаторЧленаКласса ::= ПолноеИмяКласса([СписокВыражений]) ::= Идентификатор([СписокВыражений])
ПолноеИмяКласса ::= КвалифицированноеИмяКласса
::= :: КвалифицированноеИмяКласса
Для исследования свойств ctorИнициализатора, подвергнем нашу программу очередной модификации. Мы закомментируем все ранее построенные объявления и определения конструкторов и те из операторов определения объектов класса ComplexType, которые содержали значения, определяющие начальные значения данных-членов. И сразу же начинаем определение новых вариантов конструкторов.
ComplexType():x(1) { cout "Здесь ComplexType():x(" x ")" endl; };
Перед нами конструктор с ctorИнициализатором. Эта конструкция позволяет решать проблемы начальной инициализации константных данных-членов. При работе с данными-членами класса транслятор рассматривает операцию присвоения как изменение начального значения члена. Инициализатор же отвечает непосредственно за установку этого САМОГО ПЕРВОГО значения.
В список инициализаторов разрешено включать все нестатические членам класса (объявленным без спецификатора static), но не более одного раза. Так что следующий вариант конструктора будет восприниматься как ошибочный:
ComplexType():x(1), x(2) // Ошибка. { ::::: }
Нетерминальный символ ПолноеИмяКласса определяет синтаксис инициализации нестатических объектов так называемого базового класса (об этом позже). В этом случае список выражений как раз обеспечивает инициализацию членов базового класса.
Добавим в объявление нашего класса объявление массива. Инициализация массива-члена класса при определении объекта не вызывает особых проблем (здесь следует вспомнить раздел, посвящённый массивам-параметрам). Однако в C++ отсутствует возможность инициализации нестатического константного массива-члена класса. Так что можно не стараться выписывать подобные объявления: const int xx[2]; // Бессмысленное объявление.
всё равно массив xx[2] невозможно проинициализировать. Все варианты инициализации константного нестатического массива будут отвергнуты.
ComplexType():xx(1,2) {/*…*/}; ComplexType():xx({1,2}) {/*…*/}; ComplexType():xx[0](1), xx[1](2) {/*…*/};
Согласно БНФ, в состав инициализатора могут входить только имена или квалифицированные имена. Для обозначения элемента массива этого недостаточно. Как минимум, здесь требуется выражение индексации, которое указывало бы номер элемента массива.
И всё же выход из такой ситуации существует. Можно объявить константный указатель на константу, которому в выражении инициализации можно присвоить имя ранее определённого массива:
::::: const int DefVal[2] = {1,2}; class ComplexType { ::::: const int const * px; /* Объявили константный указатель на константу. */ ::::: ComplexType():px(DefVal) {/*…*/}; ::::: };
Окольными путями мы всё же достигаем желаемого результата. Константный указатель на константу контролирует константный массив.
Услугами инициализатора могут пользоваться не только константные члены, а инициализирующие значения можно строить на основе самых разных выражений. Главное, чтобы используемые в этих выражениях имена располагались в соответствующих областях видимости:
ComplexType():px(DefVal), x(px[0]), // Транслятор уже знает, что такое px. CTcharVal(32), real(100), imag(real/25) // И здесь тоже всё в порядке. { // Здесь располагается тело конструктора. ::::: }
Язык и грамматика
Формальный язык является объединением нескольких множеств:
множества исходных символов, называемых литерами (алфавит), множества правил, которые позволяют строить из букв алфавита новые слова (правила порождения слов или идентификаторов), множества предопределённых идентификаторов или словаря ключевых слов (прочие идентификаторы называются именами), множества правил, которые позволяют собирать из имён и ключевых слов выражения, на основе которых строятся простые и сложные предложения (правила порождения операторов или предложений).
Множество правил порождения слов, выражений и предложений называют грамматикой формального языка или формальной грамматикой.
У формального языка много общего с естественным языком, предложения которого также строятся в соответствии с грамматическими правилами. Однако грамматика естественного языка, подобно наукам о природе с известной степенью достоверности описывает и обобщает результаты наблюдений за естественным языком как за явлением окружающего мира. Характерные для грамматики естественных языков исключения из правил свидетельствуют о том, что зафиксированная в грамматике языка система правил не может в точности описать все закономерности развития языка.
Формальные языки проще естественных языков. Они создаются одновременно с системой правил построения слов и предложений. Исключения из правил в формальном языке могут свидетельствовать лишь о противоречивости и некорректности системы грамматических правил.
Однако и здесь не всё так просто. В языке программирования C++ существуют так называемые дополнительные специальные правила соотнесения (соотнесения имени и его области действия - скоро мы встретимся с этими правилами). Так вот эти правила (а, может быть, соглашения?) вполне можно рассматривать как аналоги исключений, поскольку они директивно (по соглашению) отдают предпочтение одной из возможных альтернатив.
Грамматические правила можно записывать различными способами. Грамматика естественного языка традиционно описывается в виде грамматических правил на естественном языке.
Грамматика формального языка также может быть описана в виде множества правил на естественном языке. Но обычно для этого используют специальные средства записи: формулы и схемы. В качестве примера рассмотрим простой формальный язык.
Алфавит этого языка состоит из 17 букв:
А Б Е З И Й К Н О П Р С Т У Ч Ш Ы
и одного знака пунктуации - '.' (точки).
Рассмотрим систему правил, составляющих грамматику языка.
Правила словообразования (мы не будем вдаваться в их подробное описание) позволяют сформировать из букв языка 5 различных идентификаторов (имён и ключевых слов):
КУБ
ШАР
ПРОЗРАЧНЫЙ
СИНИЙ
УКРАШАЕТ
и ни одним идентификатором больше. Идентификаторы КУБ и ШАР считаются именами, прочие идентификаторы считаются ключевыми словами.
По весьма отдалённой аналогии с естественным языком, ключевые слова будут играть роли членов предложения и частей речи.
Определение сказуемого (это член предложения): ключевое слово УКРАШАЕТ будем считать сказуемым. Определение прилагательного (это часть речи): ключевые слова ПРОЗРАЧНЫЙ и СИНИЙ будем считать прилагательными. Имена играют роль существительных.
По аналогии с естественным языком, где предложения строятся из членов предложений, предложения-операторы языка состоят из членов предложений-выражений. Часть выражений считается подлежащими, часть - дополнениями.
Определение подлежащего: выражения-подлежащие состоят из ключевого слова-прилагательного и имени. Определение дополнения: выражения-дополнения состоят из ключевого слова-прилагательного и имени (одного из двух). Определение оператора (это последнее правило грамматики): предложение состоит из тройки выражений, самым первым из которых является подлежащее, затем сказуемое и дополнение. Предложение заканчивается точкой.
Только что нами была определена грамматика формального языка. Она была описана привычным способом, с помощью нескольких предложений русского языка.
Рассмотрим ещё один способ записи этой грамматики - с помощью формул. Запишем сначала в виде формулы определение оператора:
оператор ::= подлежащее сказуемое дополнение . (1)
В этой формуле символ ::= следует читать как "является" или "заменить".
Затем определим в виде формул подлежащее и дополнение: подлежащее ::= прилагательное существительное (2)
дополнение ::= прилагательное существительное (3)
Следующая формула отражает тот факт, что сказуемым является ключевое слово УКРАШАЕТ. сказуемое ::= УКРАШАЕТ (4)
Следующее правило определяет прилагательное: прилагательное ::= ПРОЗРАЧНЫЙ | СИНИЙ (5)
Здесь вертикальная черта между двумя ключевыми словами означает, альтернативу (прилагательным в выражении может быть либо ключевое слово ПРОЗРАЧНЫЙ, либо ключевое слово СИНИЙ). Существует еще, по крайней мере, один способ описания альтернативы. Воспользуемся им при определении существительного. Это правило задаёт множество имён: существительное ::= ШАР (6)
::= КУБ
Правила построения предложений в нашем языке оказались записаны с помощью шести коротких формул. Слова, стоящие справа и слева от знака "заменить" принято называть символами формальной грамматики, а сами формулы - грамматическими правилами.
Заметим, что символы в формулах грамматики не являются словами в обычном смысле этого слова. Символ в формуле является лишь своеобразным иероглифом, по внешнему виду напоминающим слово. При изменении внешнего вида символов суть формул грамматики нисколько бы не изменилась. Мы всего лишь используем возможность кодирования дополнительной информации с помощью внешнего вида символа. В надежде, что это поможет лучше понять происходящее.
Символы, которые встречаются только в левой части правил, называются начальными нетерминальными символами или начальными нетерминалами.
Символы, которые встречаются как в левой, так и в правой части грамматических правил называются нетерминальными символами.
Символы, которые встречаются только в правой части правил, называются терминальными символами.
Воспользуемся этой грамматикой и построим несколько предложений.
Алгоритм порождения операторов-предложений и отдельных выражений с помощью правил формальной грамматики очень прост:
Выбрать начальный нетерминал (оператор) или отдельный нетерминальный символ, найти правило, содержащее этот символ в левой части и заменить его на символ или на последовательность символов из правой части правила. Процесс замены продолжать до тех пор, пока в предложении будут встречаться нетерминальные символы.
Выбор нетерминального символа обеспечивает порождение выражения, выбор начального нетерминала обеспечивает вывод оператора:
оператор (1) подлежащее сказуемое дополнение . (2) прилагательное существительное сказуемое дополнение . (3) прилагательное существительное сказуемое прилагательное существительное. (4) прилагательное существительное УКРАШАЕТ прилагательное существительное. (5) ПРОЗРАЧНЫЙ существительное УКРАШАЕТ СИНИЙ существительное. (6) ПРОЗРАЧНЫЙ ШАР УКРАШАЕТ СИНИЙ КУБ.
Больше терминальных символов нет. По правилам формальной грамматики мы построили первое предложение языка. СИНИЙ КУБ УКРАШАЕТ ПРОЗРАЧНЫЙ КУБ.
Это ещё одно предложение нашего языка.
Формальная грамматика может использоваться не только для порождения предложений, но и для проверки, является ли какая-либо последовательность символов выражением языка. Для этого среди символов исследуемой последовательности надо сначала отыскать терминальные символы и применяя правила формальной грамматики, справа налево заменять терминальные символы нетерминальными, а затем "сворачивать" последовательности нетерминальных символов до тех пор, пока не будет получен начальный нетерминал, или окажется единственный нетерминальный символ.
Так последовательность символов СИНИЙ КУБ ВЕНЧАЕТ ПРОЗРАЧНЫЙ КУБ.
не является оператором языка, поскольку символ ВЕНЧАЕТ не встречается среди нетерминальных символов. В свою очередь, пара терминальных символов СИНИЙ ШАР является выражением нашего языка и может быть как подлежащим, так и дополнением, поскольку может быть преобразована как в нетерминальный символ подлежащее, так и в нетерминальный символ дополнение.
Рассмотренный нами способ записи правил грамматики языка называется формой Бэкуса-Наура (сокращенно БНФ). Зачем они и что, собственно, с ними делать?
Не бояться их. Смотреть на них. Читать их. Символы формальной грамматики складываются в основном из букв родного алфавита. Формулы кратки и информативны. Правила, для изложения которых обычно требуется несколько фраз естественного языка, часто описываются одной формой Бэкуса-Наура. После небольшой тренировки чтение этих форм становится лёгким и приятным занятием.
Впервые БНФ были использованы при описании языка программирования Алгол более 30 лет назад и до сих пор БНФ применяются для описания грамматики при разработке новых языков программирования. Это очень эффективное и мощное средство. Без лишних слов, просто, лаконично, наглядно.
Мы часто будем использовать эти формы. При этом нетерминальные символы в БНФ будут выделяться подчёркиванием.
Подобно предложениям естественного языка, которые обычно служат основой связного повествования (сказки, романа, научного исследования), предложения формального языка также могут быть использованы для описания всевозможных явлений и процессов. Множества операторов языка программирования служат для создания программ - основного жанра произведений, для которых и используются эти языки. Программы пишут для различных программируемых устройств. К их числу относятся и электронно-вычислительные машины, которые в настоящее время являются наиболее универсальными вычислительными устройствами и основными потребителями программ.
Элементы программного модуля
Мы переходим к описанию синтаксиса элементов программного модуля, но, прежде всего, определим ещё одну категорию спецификаторов объявления.
СпецификаторОбъявления ::= fctСпецификатор
::= ***** fctСпецификатор ::= inline ::= virtual
fctСпецификатор используется при объявлении и определении функций. Их назначение ещё будет обсуждаться в дальнейшем.
ЭлементПрограммногоМодуля ::= СписокИнструкцийПрепроцессора
::= СписокОператоров
СписокОператоров ::= [СписокОператоров] Оператор
Оператор ::= ОператорОбъявления
::= *****
ОператорОбъявления ::= Объявление
Объявление ::= ОбъявлениеФункции
::= ОпределениеФункции
::= *****
ОбъявлениеФункции ::= [СписокСпецификаторовОбъявления] Описатель
[СпецификацияИсключения];
ОпределениеФункции ::= [СписокСпецификаторовОбъявления] Описатель
[ctorИнициализатор] [СпецификацияИсключения] ТелоФункции
Описатель ::= ИмяОписатель
::= ptrОперация Описатель
::= Описатель (СписокОбъявленийПараметров) ::= Описатель [[КонстантноеВыражение]] ::= (Описатель)
ИмяОписатель ::= Имя
::= *****
ptrОперация ::= * [СписокCVОписателей] ::= [СписокCVОписателей]
СписокCVОписателей ::= CVОписатель [СписокCVОписателей]
CVОписатель ::= const | volatile
ctorИнициализатор ::= *****
СпецификацияИсключения ::= *****
О последних двух нетерминалах позже. КонстантноеВыражение ::= УсловноеВыражение
Свойства константного выражения мы также обсудим позже.
УсловноеВыражение ::= *****
СписокОбъявленийПараметров ::= [СписокОбъявленийПарам] [...] ::= СписокОбъявленийПарам, ...
Следует обратить особое внимание на последнюю БНФ. В ней зафиксировано различие между двумя нетерминалами. Так что СписокОбъявленийПараметров - совсем не то, что СписокОбъявленийПарам. Здесь нет никаких опечаток или ошибок. Первый нетерминал по смыслу шире второго. Три точечки, заключённые в круглые скобочки (...) уже в определённом контексте можно рассматривать как СписокОбъявленийПараметров, но это никак не СписокОбъявленийПарам. Это как раз тот самый случай, когда к нетерминалам имеет смысл относится как к СИМВОЛАМ, а не как к последовательностям подчёркнутых слов.
СписокОбъявленийПарам ::= ОбъявлениеПараметра
::= [СписокОбъявленийПарам,] ОбъявлениеПараметра
ОбъявлениеПараметра ::= СписокСпецификаторовОбъявления Описатель
::= СписокСпецификаторовОбъявления
Описатель
Инициализатор
::= СписокСпецификаторовОбъявления
[АбстрактныйОписатель] [Инициализатор]
АбстрактныйОписатель ::= ptrОперация [АбстрактныйОписатель] ::= [АбстрактныйОписатель] (СписокОбъявленийПараметров) [СписокCVОписателей] ::= [АбстрактныйОписатель] [[КонстантноеВыражение]] ::= (АбстрактныйОписатель)
БНФ, раскрывающая смысл нетерминала АбстрактныйОписатель, также проста, как и все прочие БНФ. Достаточно беглого взгляда, чтобы понять, что в роли этого самого абстрактного описателя могут выступать операции *, , даже пара символов [], между которыми может располагаться константное выражение. Абстрактный описатель можно также поместить в круглые скобки.
Если обычный описатель предполагает какое-либо имя, то абстрактный описатель предназначается для обозначения неименованных (безымянных) сущностей.
ТелоФункции ::= СоставнойОператор
СоставнойОператор ::= {[СписокОператоров]}
Фигурные скобочки - характерный признак составного оператора.
СписокОператоров ::= Оператор
::= СписокОператоров Оператор
Оператор ::= ОператорОбъявления
::= *****
СписокИнструкцийПрепроцессора ::= [СписокИнструкцийПрепроцессора] ИнструкцияПрепроцессора
ИнструкцияПрепроцессора ::= # ::= Макроопределение
::= ФункциональноеМакроопределение
::= *****
Макроопределение ::= #define Идентификатор СтрокаЛексем
ФункциональноеМакроопределение ::= #define Идентификатор (СписокИдентификаторов) СтрокаЛексем
СписокИдентификаторов ::= Идентификатор ::= СписокИдентификаторов, Идентификатор
СтрокаЛексем ::= Лексема ::= СтрокаЛексем Лексема
Составной оператор также называют блоком операторов (или просто блоком).
Несмотря на значительное количество пропусков в приведённых выше БНФ, содержащейся в них информации о синтаксисе программного модуля вполне достаточно для реконструкции его общей структуры.
Сейчас мы рассмотрим структуру модуля. На содержательную часть этой "программы" можно не обращать никакого внимания. Сейчас важен лишь синтаксис.
СписокИнструкцийПрепроцессора
СписокОператоров
Макроопределение
Оператор
Оператор
Оператор
Оператор
#define Идентификатор СтрокаЛексем
ОбъявлениеПеременной
ОбъявлениеФункции
ОпределениеФункции
ОпределениеФункции
#define IdHello "Hello…" int *pIntVal[5]; /* Объявлена переменная типа массив указателей размерности 5 на объекты типа int с именем pIntVal. */ СпецификаторОбъявления Описатель; СпецификаторОбъявления Описатель ТелоФункции
СпецификаторОбъявления Описатель ТелоФункции
#define IdHello "Hello…" int *pIntVal[5]; int Описатель (СписокОбъявленийПараметров); float Описатель (СпецификаторОбъявления Имя ) ТелоФункции
unsigned int MyFun2 (int Param1, ...) СоставнойОператор
#define IdHello "Hello…" int *pIntVal[5]; int MyFun1 ( СпецификаторОбъявления , СпецификаторОбъявления АбстрактныйОписатель Инициализатор, ); float MyFun2 (СпецификаторОбъявления ИмяОписатель) ТелоФункции
unsigned int MyFun3 (int Param1, ...) {СписокОператоров}
#define IdHello "Hello…" int *pIntVal[5]; int MyFun1 (float, int *[5] = pIntVal); /* Объявление функции. В объявлении второго параметра используется абстрактный описатель - он описывает нечто абстрактное, а, главное, безымянное, вида *[5]. Судя по спецификатору объявления int, расположенному перед описателем, "нечто" подобно массиву указателей на объекты типа int из пяти элементов (подробнее о массивах после). И эта безымянная сущность инициализируется с помощью инициализатора. Сейчас нам важно проследить формальные принципы построения программного модуля. Прочие детали будут подробно обсуждены ниже. */ float MyFun2 (char chParam1) { СписокОператоров
} unsigned int MyFun3 (int Param1, …) {СписокОператоров}
#define IdHello "Hello…" int *pIntVal[5]; int MyFun1 (float, int *[5] = pIntVal); // Объявление функции. // Определены две функции… float MyFun2 (char chParam1) { extern int ExtIntVal; char *charVal; } unsigned int MyFun3 (int Param1, …) { const float MMM = 233.25; int MyLocalVal; }
Только что на основе БНФ было построено множество предложений, образующих программный модуль. Фактически, наша первая программа ничего не делает. Всего лишь несколько примеров бесполезных объявлений и никаких алгоритмов. Тем не менее, этот пример показывает, что в программе нет случайных элементов. Каждый символ, каждый идентификатор программы играет строго определённую роль, имеет собственное название и место в программе. И в этом и состоит основная ценность этого примера.
Итак, наш первый программный модуль представляет собой множество инструкций препроцессора и операторов. Часть операторов играет роль объявлений. С их помощью кодируется необходимая для транслятора информация о свойствах объектов. Другая часть операторов является определениями и предполагает в ходе выполнения программы совершение разнообразных действий (например, создание объектов в различных сегментах памяти).
После трансляции модуля предложения языка преобразуются во множество команд процессора. При всём различии операторов языка и команд процессора, трансляция правильно написанной программы обеспечивает точную передачу заложенного в исходный текст программы смысла (или семантики операторов). Программист может следить за ходом выполнения программы по операторам программы на C++, не обращая внимания на то, что процессор в это время выполняет собственные последовательности команд.
С процессом выполнения программы связана своеобразная система понятий. Когда говорят, что в программе управление передаётся какому-либо оператору, то имеют в виду, что в исполнительном модуле процессор приступил к выполнению множества команд, соответствующих данному оператору.
Эволюция языков программирования
Устройство современных ЭВМ основано на принципах двоичной арифметики, где для представления чисел используются всего две цифры - 0 и 1. В двоичной арифметике любое число кодируется битовыми последовательностями. Вся необходимая для работы ЭВМ информация также хранится в памяти ЭВМ в битовом представлении.
Особенности устройства ЭВМ определяют способы её управления. Командами для управления ЭВМ служат всё те же битовые последовательности. Поэтому наиболее естественным способом управления ЭВМ является кодирование информации для ЭВМ в виде всё тех же битовых последовательностей. Для первых ЭВМ альтернативных способов управления просто не существовало. Алфавит языка непосредственного кодирования содержал всего две буквы (а, может быть, цифры?). Можно представить правила словообразования и внешний вид словаря этого языка. Программирование в кодах ЭВМ требует досконального знания системы команд машины и большого внимания. Кроме того, процесс программирования в кодах малоэффективен. Проблема повышения эффективности программирования возникла одновременно (а может и раньше) с появлением первых действующих вычислительных машин.
Первая попытка оптимизации программирования в двоичных кодах заключалась в разработке специальной системы кодирования двоичных машинных команд многобуквенными мнемоническими сокращениями.
Программирование в мнемонических командах удобнее для программиста, поскольку мнемонические коды содержат для программиста дополнительную информацию по сравнению с трудно различимыми последовательностями нулей и единиц. Вместе с тем текст подобной программы становится абсолютно непонятным вычислительной машине и требует специальной программы-переводчика (или транслятора), которая бы заменяла мнемонический код исходной двоичной командой. С момента реализации этой идеи кодирование становится программированием.
Языки, которые требуют предварительного перевода, называются языками высокого уровня. Считается, что эти языки в определённом смысле более близки к естественному языку. С последним утверждением можно не согласится, но одно очевидно: многолетний опыт показал, что использование языков высокого уровня значительно повышает эффективность программирования по сравнению с обычным кодированием.
Следующим шагом в развитии языков программирования явилась реализация возможности построения большой программы из отдельных фрагментов программного кода. С этой целью используются подпрограммы. Это последовательности команд, предназначенные для многократного использования в одной программе. Программирование с использованием подпрограмм требует ещё одной специальной программы, которая обеспечивает сборку единой программы из отдельных фрагментов-подпрограмм и её размещение в памяти ЭВМ. Эта программа называется компоновщиком.
Класс. Объявление класса
Класс - это тип. Этот производный тип вводится в программу с помощью специального оператора объявления класса. В объявлении класса используется ранее описанный инструментальный набор средств для построения и преобразования производных типов.
Очередное множество форм Бэкуса-Наура определяет синтаксис объявления класса.
Объявление ::= [СписокСпецификаторовОбъявления] [СписокОписателей];
СписокСпецификаторовОбъявления
::= [СписокСпецификаторовОбъявления] СпецификаторОбъявления
СпецификаторОбъявления ::= СпецификаторТипа
::= *****
СпецификаторТипа ::= СпецификаторКласса
::= УточнённыйСпецификаторТипа
::= *****
УточнённыйСпецификаторТипа ::= КлючевоеСловоКласса ИмяКласса
::= КлючевоеСловоКласса Идентификатор ::= enum ИмяПеречисления
КлючевоеСловоКласса ::= union ::= struct ::= class
ИмяКласса ::= Идентификатор
СпецификаторКласса ::= ЗаголовокКласса {[СписокЧленов]}
ЗаголовокКласса
::= КлючевоеСловоКласса [Идентификатор] [СпецификацияБазы] ::= КлючевоеСловоКласса ИмяКласса [СпецификацияБазы]
КлючевоеСловоКласса ::= union ::= struct ::= class
ИмяКласса ::= Идентификатор
Спецификатор класса представляет то, что называется объявлением класса. Уточнённый спецификатор типа объявляет расположенный за ним идентификатор именем класса. Уточнённый спецификатор обеспечивает неполное предварительное объявление класса и перечисления.
Назначение и смысл необязательного нетерминального символа СпецификацияБазы будут обсуждаться позже, в разделах, посвящённых наследованию.
Предварительное объявление обеспечивается уточнённым спецификатором типа и является своеобразным прототипом класса или перечисления. Его назначение - сообщение транслятору предварительной информации о том, что существует (должно существовать) объявление класса (или перечисления) с таким именем. Идентификатор, используемый в контексте уточнённого спецификатора имени становится именем класса (или именем перечисления).
Класс считается объявленным даже тогда, когда в нём полностью отсутствует информация о членах класса (пустой список членов класса). Неименованный класс с пустым множеством членов - уже класс!
Имя класса можно употреблять как имя (имя типа) уже в списке членов этого самого класса.
Класс может быть безымянным.
Следующая последовательность операторов объявления
class {}; /* Объявлен пустой неименованный класс.*/ class {}; class {}; class {}; /* Это всё объявления. Их количество ничем не ограничивается. */ struct {}; /* Структура - это класс, объявленный с ключевым словом struct. Опять же пустой и неименованный.*/
не вызывает у транслятора никаких возражений.
На основе класса, пусть даже неименованного, может быть объявлен (вернее, определён) объект-представитель этого класса. В таком контексте объявление неименованного (пусть даже и пустого!) класса является спецификатором объявления. Имена определяемых объектов (возможно с инициализаторами) составляют список описателей.
class {} Obj1, Obj2, Obj3;/* Здесь объявление пустого класса.*/ class {} Obj4, Obj5, Obj6;/* Просто нечего инициализировать.*/ class {} Obj1; /* ^ Ошибка. Одноименные объекты в области действия имени.*/
Неименованные классы также можно применять в сочетании со спецификатором typedef (здесь может быть объявление класса любой сложности - не обязательно только пустой). Спецификатор typedef вводит новое имя для обозначения безымянного класса. Описанное имя типа становится его единственным именем.
Сочетание спецификатора typedef с объявлением безымянного класса подобно объявлению класса с именем:
class MyClass {/*…*/}; typedef class {/*…*/} MyClass;
Правда в первом случае класс имеет собственное имя класса, а во втором - описанное имя типа. Использование описанного имени типа в пределах области действия имени делает эквивалентными следующие определения (и им подобные):
class {} Obj1; MyClass Obj1;
Класс считается объявленным лишь после того, как в его объявлении будет закрыта последняя фигурная скобка. До этого торжественного момента информация о структуре класса остаётся неполной.
Если можно ОБЪЯВИТЬ пустой класс, то можно ОПРЕДЕЛИТЬ и объект-представитель пустого класса. Эти объекты размещаются в памяти. Их размещение предполагает выделение объекту участка памяти с уникальным адресом, а это означает, что объекты пустого класса имеют ненулевой размер.
Действительно, значения выражений sizeof(MyClass) и sizeof(MyObj1) (это можно очень просто проверить) отличны от нуля.
А вот пустое объединение (ещё одна разновидность класса - класс, объявленный с ключевым словом union) не объявляется: union {}; /* Некорректное объявление объединения. */
При объявлении объединения требуется детальная информация о внутреннем устройстве этого объединения.
Мы продолжаем формальное определение класса. Теперь рассмотрим синтаксис объявления членов класса.
СписокЧленов ::= ОбъявлениеЧленаКласса [СписокЧленов] ::= СпецификаторДоступа : [СписокЧленов]
ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::= ОбъявлениеФункции
::= ОпределениеФункции [;] ::= КвалифицированноеИмя;
СписокОписателейЧленовКласса ::= ОписательЧленаКласса
::= СписокОписателейЧленовКласса, ОписательЧленаКласса
ОписательЧленаКласса ::= Описатель [ЧистыйСпецификатор] ::= [Идентификатор] : КонстантноеВыражение
ЧистыйСпецификатор ::= = 0
КвалифицированноеИмяКласса ::= ИмяКласса
::= ИмяКласса :: КвалифицированноеИмяКласса
СпецификаторДоступа ::= private ::= protected ::= public
Список членов определяет полный набор членов данного класса. В этом списке объявляются все члены класса. Таковыми могут быть данные, функции-члены, ранее объявленные классы, перечисления, битовые поля, дружественные функции и даже имена типов. Некоторые из перечисленных понятий нам уже знакомы, о других речь ещё впереди. Этот список не подлежит модификации. Он формируется за один раз.
В соответствии с синтаксическими правилами, членами класса могут быть как определения функций, так и их прототипы. Действительно:
ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::= СпецификаторОбъявления ОписательЧленаКласса; ::= СпецификаторТипа Описатель; ::= void Описатель (СписокОбъявленийПараметров); ::= void ff (void);
С другой стороны,
ОбъявлениеЧленаКласса ::= ОпределениеФункции [;] ::= Описатель (СписокОбъявленийПараметров) ТелоФункции ::= ff (void) {int iVal = 100;}
В соответствии с синтаксическими правилами, членами класса могут быть как определения функций, так и их прототипы. Действительно:
ОбъявлениеЧленаКласса ::= [СписокСпецификаторовОбъявления] [СписокОписателейЧленовКласса]; ::= СпецификаторОбъявления ОписательЧленаКласса; ::= СпецификаторТипа Описатель; ::= void Описатель (СписокОбъявленийПараметров); ::= void ff (void);
С другой стороны,
ОбъявлениеЧленаКласса ::= ОпределениеФункции [;] ::= Описатель (СписокОбъявленийПараметров) ТелоФункции ::= ff (void) {int iVal = 100;}
Точка с запятой после определения функции является декоративным элементом. Ни один член класса не может входить в список членов класса дважды. Поэтому определяемая в теле класса функция оказывается без прототипа. Если класс содержит прототип функции в качестве члена класса, функция располагается за пределами класса. Как мы скоро увидим, всё разнообразие объявлений и определений функций-членов транслятор приводит к единому стандартному виду.
Функции-члены могут определяться вне списка членов класса. При определении функции-члена класса за пределами данного класса, в списке членов класса размещается прототип функции-члена. А при определении функции-члена используется квалифицированное имя. Квалифицированное имя состоит из последовательности имён классов, разделённых операциями разрешения области видимости. Эта последовательность имён завершается именем определяемой функции. Последовательность имён классов в квалифицированных именах определяется степенью вложенности объявлений классов.
Наличие функций-членов делает объявление класса подобным определению (как и любые функции, функции-члены определяются). Как сказано в Справочном руководстве по C++, "Если бы не исторические причины, объявление класса следовало называть определением класса".
Данные-члены класса не могут объявляться со спецификаторами auto, extern, register.
Ни при каких обстоятельствах не допускается объявление одноименных членов. Имена данных-членов должны также отличаться от имён функций-членов. Использование одноимённых функций, констант и переменных в выражениях в пределах одной области действия имён приводит к неоднозначности. Как известно, имя функции, как и имя константы и переменной, является выражениями. Если допустить объявление одноимённых переменных, констант и функций, то в ряде случаев просто невозмо будет определить, о чём в программе идёт речь.
Объявляемые в классе данные-члены, которые являются представителями классов, должны представлять ранее объявленные классы. Транслятор должен знать заранее о структуре подобных данных-членов.
Описатель члена класса в объявлении класса не может содержать инициализаторов (это всего лишь объявление).
Структура является классом, объявленным с ключевым словом класса struct. Члены такого класса и базовые классы по умолчанию обладают спецификацией доступа public.
Назначение спецификаторов доступа будет обсуждаться в разделах, посвящённых управлению доступом. Пока будет достаточно в объявлении класса указать спецификатор public. В этом случае члены класса оказываются доступны (к ним можно будет свободно обращаться) из любого оператора программы.
Объединение является классом, объявленным с ключевым словом класса union. Его члены также по умолчанию обладают спецификацией доступа public. В каждый момент исполнения программы объединение включает единственный член класса. В этом его специфика. Именно поэтому не может быть пустого объединения. Позже мы вернёмся к объединениям.
Если функция-член определяется вне тела класса, в список членов класса включается прототип функции. Определение функции сопровождается квалифицированным именем, которое указывает транслятору на принадлежность определяемой функции-члена классу. Последняя часть квалифицированного имени (собственно имя функции) должна совпадать с именем прототипа функции-члена, объявленного ранее в классе.
Подобно определению данных основных типов, в программе могут быть определены объекты ранее объявленного типа. В ходе определения объекта-представителя класса выделяется память для размещения данных-членов класса. При этом непосредственно в этой области памяти размещаются все данные-члены, за исключением данных, объявленных со спецификатором static (об этом спецификаторе будет сказано ниже).
Разбор структуры класса осуществляется транслятором в несколько этапов.
На первом этапе исследуется список данных-членов класса. Именно этот список и определяет общую структуру класса. До окончания этой стадии разбора класса, а фактически до завершения объявления класса, его имя в объявлении данных-членов может быть использовано лишь в таком контексте, где не используется информация о размерах класса. Это объявления указателей, ссылок и статических членов класса (о них после).
Таким образом, объект-представитель класса не может быть членом собственного класса, поскольку объект-представитель класса может быть объявлен как член класса лишь после того, как завершено объявление этого класса.
Функция-член класса существует в единственном экземпляре для всех объектов-представителей данного класса. Переобъявление и уточнение структуры класса в С++ недопустимо.
Серия простых примеров демонстрирует, что можно, а что нельзя делать при объявлении данных-членов класса.
class C1 { C1 MyC; // Это ошибка. В классе не допускается объявления данных-членов // объявляемого класса. C1* pMyC; // А указатель на класс объявить можно. };
Для объявления таких указателей или ссылок на объекты объявляемого класса достаточно неполного предварительного объявления класса. Указатели и ссылки имеют фиксированные размеры, которые не зависят от типа представляемого объекта.
class C2; class C1 { C1* pMyC1; C2* pMyC2; }; C2* PointOnElemOfClassC2;
Назначение неполного объявления подобно прототипу функции и используется исключительно в целях предварительного информирования транслятора. Очевидно, что создание объектов на основе предварительного неполного объявления невозможно. Однако это не снижает ценности уточнённого спецификатора.
На втором проходе трансляции объявления класса осуществляется проверка списков параметров в объявлениях функций-членов класса, и определяется размер класса. К этому моменту транслятору становится известна общая структура класса. И потому, как ни странно это выглядит, в классе может быть объявлена функция-член класса, которая возвращает значение объявляемого класса и содержит в списке параметров параметры этого же класса:
class C2; class C1 { C1 F1(C1 par1) {return par1;}; //Объявить данные-члены класса C1 нельзя, а функцию - можно! C1* pMyC1; C2* pMyC2; // C1 MyC; }; C2* PointOnElemOfClassC2;
Где бы ни располагалась объявляемая в классе функция-член, транслятор приступает к её разбору лишь после того, как он определяет общую структуру класса.
В соответствии с формальным определением создадим наш первый класс:
СпецификаторКласса ::= ЗаголовокКласса { [СписокЧленов] }; ::= КлючевоеСловоКласса Идентификатор { ОбъявлениеЧленаКласса
ОбъявлениеЧленаКласса }; ::= class FirstClass { СпецификаторОбъявления ОписательЧленаКласса; ОписаниеФункции; }; ::= class FirstClass { СпецификаторОбъявления ОписательЧленаКласса; int FirstClassFunction(void);}; ::= class FirstClass { long int* PointerToLongIntVal; int FirstClassFunction(void); };
За исключением квалифицируемого имени синтаксис определения функции-члена класса вне класса ничем не отличается от определения обычной функции:
int FirstClass::FirstClassFunction(void) { int IntVal = 100; return IntVal; };
Вот таким получилось построенное в соответствии с грамматикой C++ определение (или объявление) класса.
Заметим, что в C++ существует единственное ограничение, связанное с расположением определения функции-члена класса (конечно, если оно располагается вне тела класса): определение должно располагаться за объявлением класса, содержащего эту функцию. Именно "за объявлением"! Без каких-либо дополнительных ограничений типа "непосредственно за" или "сразу за".
Более того, в ряде случаев, например, когда требуется определить функцию-член, изменяющую состояние объекта другого класса, данная функция-член должна располагаться за объявлением класса, состояние объекта которого она изменяет. И это понятно. При разборе такой функции-члена транслятор должен иметь представление о структуре класса.
Допускается и такая схема расположения объявлений, при которой первыми располагаются неполные объявления классов, следом соответствующие объявления классов и лишь затем определения функций-членов. Подобные определения мы будем называть отложенными определениями. Позже мы рассмотрим пример программы, в которой отложенный вариант определения функции-члена является единственно возможным вариантом определения.
Класс - это то, что делает C++ объектно-ориентированным языком. На основе классов создаются новые производные типы и определяются функции, которые задают поведение типа.
Рассмотрим несколько строк программного кода, демонстрирующих свойства производных типов.
class Class1 {int iVal;}; class Class2 {int iVal;}; /* Объявление производных типов Class1 и Class2. Эти объявления вводят в программу два новых производных типа. Несмотря на тождество их структуры, это разные типы. */ void ff(Class1); /* Прототип функции с одним параметром типа Class1.*/ void ff(Class2); /* Прототип функции с одним параметром типа Class2. Это совместно используемые (или перегруженные) функции. Об этих функциях мы уже говорили. */ Class1 m1; /* Объявление объекта m1 типа Class1. */ Class2 m2; /* Объявление объекта m2 типа Class2. */ int m3; m1 = m2; m1 = m3; m3 = m2; /* Последние три строчки в данном контексте недопустимы. Неявное преобразование с участием производных типов в C++ невозможно. Транслятор не имеет никакого понятия о том, каким образом проводить соответствующее преобразование. При объявлении классов необходимо специально определять эти алгоритмы. */ void ff (Class1 pp) // Определение первой совместно используемой функции... { ::::: } void ff (Class2 pp) // Определение второй совместно используемой функции... { ::::: } ff(m1);//Вызов одной из двух совместно используемых функций... 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; } /* Полный разбор операторов в теле функции производится лишь после полного разбора объявления класса. К этому моменту размер класса уже будет определён. */ }
Класс памяти
Класс памяти определяет порядок размещения объекта в памяти. Различают автоматический и статический классы памяти. C++ располагает четырьмя спецификаторами класса памяти:
auto register static extern
по два для обозначения принадлежности к автоматическому и статическому классам памяти.
В свою очередь, статический класс памяти может быть локальным (внутренним) или глобальным (внешним).
Следующая таблица иллюстрирует иерархию классов памяти.
Динамический класс памяти | Статический класс памяти | ||
Автоматический | Регистровый | Локальный | Глобальный |
auto | register | static | Extern |
Спецификаторы позволяют определить класс памяти определяемого объекта:
auto. Этот спецификатор автоматического класса памяти указывает на то, что объект располагается в локальной (или автоматически распределяемой) памяти. Он используется в операторах объявления в теле функций, а также внутри блоков операторов. Объекты, имена которых объявляются со спецификатором auto, размещаются в локальной памяти непосредственно перед началом выполнения функции или блока операторов. При выходе из блока или при возвращении из функции (о механизмах вызова функций и возвращения из них речь ещё впереди), соответствующая область локальной памяти освобождается и все ранее размещённые в ней объекты уничтожаются. Таким образом спецификатор влияет на время жизни объекта (это время локально). Спецификатор auto используется редко, поскольку все объекты, определяемые непосредственно в теле функции или в блоке операторов и так по умолчанию располагаются в локальной памяти. Вне блоков и функций этот спецификатор не используется. register. Ещё один спецификатор автоматического класса памяти. Применяется к объектам, по умолчанию располагаемым в локальной памяти. Представляет из себя "ненавязчивую просьбу" к транслятору (если это возможно) о размещении значений объектов, объявленных со спецификатором register в одном из доступных регистров, а не в локальной памяти. Если по какой-либо причине в момент начала выполнения кода в данном блоке операторов регистры оказываются занятыми, транслятор обеспечивает с этими объектами обращение, как с объектами класса auto. Очевидно, что в этом случае объект располагается в локальной области памяти. static. Спецификатор внутреннего статического класса памяти. Применяется только(!) к именам объектов и функций. В C++ этот спецификатор имеет два значения. Первое означает, что определяемый объект располагается по фиксированному адресу. Тем самым обеспечивается существование объекта с момента его определения до конца выполнения программы. Второе значение означает локальность. Объявленный со спецификатором static локален в одном программном модуле (то есть, недоступен из других модулей многомодульной программы) или в классе (о классах - позже). Может использоваться в объявлениях вне блоков и функций. Также используется в объявлениях, расположенных в теле функций и в блоках операторов. extern. Спецификатор внешнего статического класса памяти. Обеспечивает существование объекта с момента его определения до конца выполнения программы. Объект, объявленный со спецификатором extern доступен во всех модулях программы, то есть глобален.
Выбор класса памяти, помимо явных спецификаторов, зависит от размещения определения или объявления в тексте программы. Модуль, функция, блок могут включать соответствующие операторы объявления или определения, причём всякий раз определяемый объект будет размещаться в строго определённых областях памяти.
Ключевые слова и имена
Часть идентификаторов C++ входит в фиксированный словарь ключевых слов. Эти идентификаторы образуют подмножество ключевых слов (они так и называются ключевыми словами). Прочие идентификаторы после специального объявления становятся именами. Имена служат для обозначения переменных, типов данных, функций и меток. Обо всём этом позже.
Ниже приводится список ключевых слов:
asm auto break case catch char class const continue default do double else enum extern float for friend goto if inline int long new operator private protected public register return short signed sizeof static struct switch template this throw try typedef typeid union unsigned virtual void volatile while.
Комментарии: возможность выразиться неформально
Язык программирования C++, как и любой формальный язык непривычен для восприятия и в силу этого в ряде случаев может быть тяжёл для понимания. В C++ предусмотрены дополнительные возможности для облегчения восприятия текстов программ. Для этого используются комментарии.
Комментарии - это любые последовательности символов алфавита C++, заключённые в специальные символы. Эти символы называются символами - комментариями. Существуют две группы символов - комментариев. К первой группе относятся парные двухбуквенные символы /* и */.
Ко второй группе символов - комментариев относится пара, состоящая из двухбуквенного символа // и не имеющего графического представления пробельного символа новой строки.
Последовательность символов, ограниченная символами комментариев, исчезает из поля зрения транслятора. В этой "мёртвой зоне" программист может подробно описывать особенности создаваемого алгоритма, а может просто "спрятать" от транслятора целые предложения на C++.
Константные выражения
КонстантноеВыражение ::= УсловноеВыражение
В ряде случаев C++ требует, чтобы вычисляемое значение выражения было целочисленной константой. Это относится к границам массивов, размерам битовых полей, значениям инициализаторов элементов перечисления. Константные выражения представляют собой неизменяемые целочисленные значения. Они строятся на основе литералов, элементов перечисления (о них речь впереди), проинициализированных целочисленных констант, выражений, построенных на основе операции sizeof.
Константное выражение не меняет своего значения. Поэтому константное выражение не может быть именем переменной или выражением, которое включает имя переменной.
Константные выражения вычисляются на стадии трансляции, а потому в константном выражении не могут использоваться функции, объекты классов, указатели, ссылки, операция запятая и операция присваивания.
Константное выражение может состоять из литералов, имён констант, элементов перечисления (о них позже), может содержать символы арифметических операций, которые связывают константные выражения.
Основное назначение константного выражения в C++ - фиксация значений ограниченного множества значений, предназначенных для организации управленния процессом выполнения программы, задание предопределённых характеристик объектов (например, размер массива). Управление выполнением и характеристика размерности не требует особой точности. Органы управленния должны быть максимально простыми, количество элементов и длина в байтах задаются целочисленными значениями. Здесь нет проблем, связанных с точностью вычисления, здесь достаточно значений интегрального типа.
Значение константного выражения определяется уже на стадии трансляции, поскольку размерность массива и метка помеченного оператора в операторе выбора должны быть известны до начала выполнения программы. А это ещё один аргумент в пользу запрещения включения в константное выражение вызовов функций (на стадии трансляции нет возвращаемых значений).
По этой же причине константное выражение не может быть указателем или ссылкой (о ссылках - позже), поскольку всё, что связано с адресами, определяется лишь на этапе выполнения программы.
Константное выражение не может содержать операцию присваивания, операции инкрементации и декрементации.
А вот операции сравнения, арифметические операции, операция sizeof и, как ни странно, операция запятая не вызывают возражений транслятора (транслятор и считать умеет, и сравнивать, он и размеры определяет, а также понимает, какое значение следует присвоить выражению, содержащему символ операции запятая).
Конструктор копирования
Если определить переменную основного типа и присвоить ей значение, то выражение, состоящее из имени переменной, получит соответствующее значение. Имя означенной переменной можно расположить справа от знака операции присвоения. В результате выполнения этой операции присвоения, леводопустимое выражение окажется равным значению ранее объявленной и проинициализированной нами переменной. Произойдёт копирование значений объектов.
int iVal1; int iVal2; iVal1 = 100; iVal2 = iVal1;
Это нам давно известно. Это тривиально. Менее тривиальным оказывается результат выполнения операции присвоения для объектов-представителей класса.
Вернёмся к старой версии конструктора (её проще повторно воспроизвести, чем описывать словами) и снова модифицируем main процедуру нашей программы. Мы определяем новый объект, используем операцию присвоения и наблюдаем за результатами:
ComplexType() { real = 0.0; imag = 0.0; CTcharVal = 0; x = 0; cout "Здесь ComplexType() конструктор!" endl; } ::::: void main() { ComplexType CDw1; ComplexType CDw2 = CDw1; cout "(" CDw1.real ", " CDw1.imag "i)" endl; cout (int)CDw1.CTcharVal ", " CDw1.x "…" endl; cout "(" CDw2.real ", " CDw2.imag "i)" endl; cout (int)CDw2.CTcharVal ", " CDw2.x "…" endl; }
Наша программа состоит из двух операторов определения, один из которых содержит описатель-инициализатор, и двух пар операторов вывода, которые сообщают о состоянии новорожденных объектов.
В программе определяется два объекта. Можно предположить, что у этих объектов окажутся одинаковые значения данных-членов. Было бы странно, если бы результат операции присвоения для основных типов по своему результату отличался бы от операции присвоения для данных производных типов.
Действительно, судя по поступающим сообщениям, оба объекта успешно были созданы и существуют с одинаковыми значениями данных-членов. При этом мы имеем дело с разными объектами, которые располагаются по разным адресам. В этом можно убедиться, если добавить оператор вывода в конец функции main: if (CDw1 != CDw2) cout "OK!" endl; /* Сообщить о разных адресах.*/
И всё же выполнение этой тривиальной программы приводит к неожиданному результату: создавая два объекта, мы наблюдаем всего одно сообщение о работе конструктора.
Остаётся предположить, что за процесс создания объекта с одновременным копированием значений данных-членов другого объекта, отвечает конструктор ещё неизвестного нам типа.
Так и есть! Такой конструктор существует и называется конструктором копирования. Вместе с конструктором умолчания, конструктор копирования входит в обязательный набор конструкторов для любого класса. Реализация механизма копирования значений для транслятора не является неразрешимой задачей. Конструктор копирования всего лишь создаёт копии объектов. Этот процесс реализуется при помощи стандартного программного кода. И построить такой код транслятор способен самостоятельно.
Здесь и далее, в примерах нами будет применяться операция присвоения = . В определённом смысле эта операция подобна конструктору. Реализующий эту операцию код автоматически создаётся на этапе трансляции для любого класса. Как и генерация кода стандартных конструкторов, это не самая сложная задача.
Подобно конструктору умолчания, конструктор копирования наряду с уже известной нам формой вызова ComplexType CDw2 = CDw1;
имеет несколько альтернативных, приводящих к аналогичному конечному результату вызовов:
ComplexType CDw2(CDw1); ComplexType CDw3 = ComplexType(CDw1);
Обе альтернативные формы вызова напоминают нам уже известные формы вызова конструкторов с параметрами. Чтобы восстановить структуру заголовка конструктора копирования, мы должны лишь определить тип его параметра.
На первый взгляд, здесь всё просто. В качестве значения параметра конструктору передаётся имя объекта, значит можно предположить, что тип параметра конструктора копирования соответствует данному классу. Так, в нашем случае, конструктор копирования класса ComplexType должен был бы иметь параметр типа ComplexType. Однако это не так. И вот почему.
В C++ конструктор копирования является единственным средством создания копий объекта.
С другой стороны, конструктор копирования - это конструктор, который поддерживает стандартный интерфейс вызова функций. Это означает, что параметры при обращении к конструктору, подобно параметрам функции передаются по значению. Если выражение вызова содержит значения параметров, то в ходе его реализации в области активации функции создаётся копия этих значений.
В таком случае, вызов конструктора копирования сопровождался бы построением в области активации конструктора копии объекта. Для этого пришлось бы использовать конструктор копирования как единственное средство построения копии объекта. Таким образом, вызов подобного конструктора копирования сопровождался бы бесконечной рекурсией.
Итак, КОНСТРУКТОР КОПИРОВАНИЯ КЛАССА X НЕ МОЖЕТ ИМЕТЬ ПАРАМЕТР ТИПА X. Это аксиома.
На самом деле, в конструкторе копирования класса X в качестве параметра используется ссылка на объект этого класса. Причём эта ссылка объявляется со спецификатором const. И в этом нет ничего странного. Как известно, выражение вызова функции с параметром типа X ничем не отличается от выражения вызова функции, у которой параметром является ссылка на объект типа X. При вызове такой функции не приходится копировать объекты как параметры. Передача адреса не требует копирования объекта, а значит, при этом не будет и рекурсии.
Конструктор копирования - обязательный элемент любого класса. Он также может быть переопределён подобно конструктору умолчания. При этом работа со ссылками в конструкторе копирования не требует явного использования операции разыменования. А спецификатор const (конструктор копирования работает с адресом объекта) предохраняет объект-параметр от случайной модификации в теле конструктора.
Конструкторы и деструкторы: заключительные замечания
В общих чертах, мы закончили описание конструкторов и деструкторов - важных элементов любого класса. Хотя в дальнейшем нам придётся ещё несколько раз обратиться к этому вопросу, главное уже позади.
И всё же следует сделать несколько замечаний.
Конструктор превращает фрагмент памяти в объект. Посредством операции обращения непосредственно "от имени" объекта можно вызвать функции-члены класса.
Мы можем модифицировать известный нам класс комплексных чисел, определив новую функцию-член reVal(), предназначенную для вывода значения действительной части комплексного числа:
class ComplexType { public: ::::: // Пусть это будет встроенная функция. void reVal(){cout real endl;}; ::::: };
И после определения объекта CDw1, мы можем вызывать эту функцию-член класса. В результате выполнения функции будет выведено значение действительной части объекта CDw1. Важно, что объект используется как точка вызова функции: CDw1.PrintVal();
А вот аналогичного выражения, обеспечивающего неявный вызов конструктора из объекта, как известно, не существует.
CDw1.ComplexType(CDw1); // Неудачная попытка неявного вызова конструктора копирования. // НЕ ОБЪЕКТ ДЛЯ КОНСТРУКТОРА, А КОНСТРУКТОР ДЛЯ ОБЪЕКТА!
По аналогии с конструкторами копирования и преобразования в C++ можно использовать функциональную форму операторов определения переменных основных типов. Синтаксис этих операторов напоминает операторы, содержащие выражения, вычисление которых обеспечивает вызов конструкторов копирования и преобразования:
ComplexType CDw1(125); ComplexType CDw2(CDw1); int iVal1(25); // Соответствует int iVal1 = 25; int iVal2(iVal1); // Соответствует int iVal2 = iVal1;
Конечно же, это не имеет никакого отношения к классам. Но вместе с тем, здесь мы можем наблюдать, как меняется грамматика при введении в язык новых типов: корректное выражение для производных типов по возможности ничем не должно отличаться от выражения для основного типа. Синтаксис операторов определение и инициализации объектов производных типов влияет на синтаксис операторов определения основных типов.
Последнее, что нам осталось сделать - это выяснить причины, по которым в C++ так различаются синтаксис объявления, определения и вызова конструкторов и деструкторов и обычных функций-членов класса.
Причина сокрытия кода регламентных работ по созданию объекта в конструкторе очевидна. Конструктор выполняет сложную работу, связанную с распределением глобальной, локальной и, как будет скоро показано, динамической памяти и превращением фрагмента памяти в объект. Это основания языка. Содержание этого процесса просто обязано быть скрытым от пользователя (программиста) подобно тому, как скрыт программный код, который реализует, например, операцию индексации, сравнения, сдвига, вызов функций и прочие языковые конструкции.
Также скрыты от нас и особенности реализации деструкторов. Не существует даже средства стандартной эффективной проверки результата выполнения деструктора: в некоторых реализациях можно обратиться к функциям-членам объекта даже после уничтожения этого объекта деструктором.
Отсутствие спецификации возвращаемого значения и запрещение операции взятия адреса для конструктора и деструктора также имеют свои объективные причины.
Если бы в их объявлениях присутствовала спецификация возвращаемого значения (неважно, какого) и было бы разрешено применение операции взятия адреса, то можно было бы в программе определять указатели на конструкторы и деструкторы как на обычные функции.
Как известно, указатель на функцию характеризуется типом возвращаемого значения и списком параметров функции. Очевидно, что имя функции в этом случае не играет никакой роли. Но как раз имя конструктора и деструктора и позволяет транслятору различать функции, конструкторы и деструкторы. При использовании указателей для вызова функций, деструкторов и конструкторов транслятор в ряде случаев просто не сможет определить, что, собственно, хочет сделать программист в данном контексте: вызвать функцию или определить новый объект.
Дополнительные ограничения при объявлении и использовании конструкторов полностью устраняют недоразумения, которые могут возникнуть при вызове функций и конструкторов.
Конструкторы. Основные свойства
Сначала несколько форм Бэкуса-Наура.
Объявление ::= ОбъявлениеФункции
::= ОпределениеФункции
::= *****
ОбъявлениеФункции ::= [СписокСпецификаторовОбъявления] Описатель
[СпецификацияИсключения];
ОпределениеФункции ::= [СписокСпецификаторовОбъявления] Описатель
[ctorИнициализатор] [СпецификацияИсключения] ТелоФункции
Описатель ::= Описатель ([СписокОбъявленийПараметров]) ::= dИмя
dИмя ::= ИмяКласса
Используя это множество БНФ, можно строить объявления весьма странного вида:
ОбъявлениеФункции ::= Описатель; ::= Описатель (); ::= dИмя (); ::= ComplexType ();
Объявление… без спецификатора объявления.
ОпределениеФункции ::= Описатель ТелоФункции ::= Описатель () {} ::= dИмя () {} ::= ComplexType () {}
А это определение. Оно построено в соответствии с правилами построения функций. Не важно, что у него в теле нет ни одного оператора! Важно, что у него нет спецификатора объявления.
Именно так и выглядит конструктор, альтернативный тому, который строится транслятором без участия программиста. Множество операторов (возможно пустое), оформленное в виде блока, с заголовком специального вида (ни слова о возвращаемых значениях) - нечто подобное функции-члену. Подобным образом организованная и весьма напоминающая своим синтаксисом обыкновенную функцию последовательность операторов и отвечает за создание объектов данного класса.
Отметим одно очень важное обстоятельство. Имя конструктора всегда совпадает с именем класса, членом которого является объявляемый конструктор. Ни одна функция-член класса не может называться именем класса. Ни одна функция-член класса не может быть объявлена и определена без спецификатора объявления. Характерное имя и отсутствие спецификации объявления отличает конструктор от функций-членов класса.
Отсутствие спецификаторов объявления означает, что конструктор не имеет абсолютно никакого отношения к вызову и возвращению значений. Конструктор не является функцией.
Так что объявления функций-членов класса ComplexType
void ComplexType(); ComplexType ComplexType();
не являются объявлениями конструктора. Для транслятора это всего лишь некорректные объявления функций-членов с пустыми списками параметров. Подобные объявления в классе ComplexType воспринимаются транслятором как ошибки.
А вот построенное нами объявление действительно является объявлением конструктора: ComplexType();
И наше определение действительно является определением конструктора: ComplexType(){}
Это ничего, что конструктор такой простой, зато он от начала и до конца правильный!
Как известно, в классе может быть не объявлено ни одного конструктора. В таком случае транслятор без участия программиста самостоятельно строит стандартный конструктор. Не существует классов без конструкторов, хотя классы с автоматически создаваемыми конструкторами, как ни странно, называются классами без конструкторов.
В классе может быть объявлено (и определено) несколько конструкторов. Их объявления должны различаться списками параметров. Такие конструкторы по аналогии с функциями называются перегруженными (или совместно используемыми). Транслятор различает перегруженные конструкторы по спискам параметров. В этом смысле конструктор не отличается от обычной функции-члена класса:
ComplexType(double rePar, double imPar); /* Объявление… */ ComplexType(double rePar, double imPar){/*…*/} /*Определение…*/
И ещё один вариант конструктора для класса ComplexType - на этот раз с одним параметром (его помощью, например, можно задавать значение мнимой части):
ComplexType(double imPar); /* Объявление… */ ComplexType(double imPar){/*…*/} /*Определение…*/
Здесь мы сознательно опять оставили пустыми тела конструкторов. Необходимо сначала выяснить, какие операторы могут, а какие не могут располагаться в конструкторе.
Отсутствие спецификации возвращаемого значения не означает запрета на использование оператора return в теле конструктора. В конце концов, это всего лишь оператор перехода. Но использование этого оператора в сочетании с выражением, задающим возвращаемое значение, например,
return NULL; либо return MyVal; либо return 125;
и т.д., недопустимо. Возвращаемое значение специфицируется по типу, а как раз про тип возвращаемого конструктором значения в объявлении конструктора ничего и не сказано. Поэтому то, что обычно называется выражением явного вызова конструктора, вызовом, по сути, не является.
Часто вообще невозможно сказать что-либо определённое по поводу того, что обеспечивает передачу управления конструктору - так называемое выражение вызова (или обращения к конструктору), либо выражение, которое используется для преобразования типа (постфиксный вариант выражения преобразования типа). Соответствующая БНФ уже приводилась ранее. Напомним её:
ПосфиксноеВыражение ::= ИмяПростогоТипа ([СписокВыражений])
ИмяПростогоТипа и имя конструктора совпадают. Поэтому имя простого типа можно рассматривать как имя конструктора. При вычислении значения выражения приведения для производных типов управление действительно передаётся одноименному конструктору. Без участия конструктора невозможно определить значение соответствующего выражения:
(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 (char(charVal)) {/*…*/} if (float(5)) {/*…*/} if ((int)3.14){/*…*/} if (double (0)){/*…*/}
Включение в условия условных операторов выражений, вычисление значений которых приводит к передаче управления конструкторам, требует дополнительных усилий со стороны прораммиста. У порождаемых конструкторами объектов сложная структура и неизвестные транслятору способы определения значений, представляемых такими объектами. Кроме того, определённый в языке набор операций приспособен исключительно для работы со значениями основных типов. Транслятор не имеет абсолютно никакого представления о том, каким образом следует, например, сравнивать значения того же самого ComplexType.
Однако, C++ располагает специальными средствами, которые позволяют создавать иллюзию условных выражений с объектами-операндами производных типов. Чуть позже мы рассмотрим так называемые операторные функции (или перегруженные операции), с помощью которых можно будет всё-таки сформулировать условия, подобные тем, которые формулируются относительно значений основных типов:
if (ComplexType()){/*…*/} if (ComplexType() 10 ComplexType() = 25 ){/*…*/}
Правда, в данном контексте за символами операций сравнения и даже за выражением "явного вызова конструктора" скрываются так называемые сокращённые формы вызова операторных функций, а не обычные операции C++.
А какое условие можно сформулировать в терминах операций, пригодных для работы исключительно со значениями основных типов по поводу значения безымянного объекта производного типа, который, к тому же и погибает сразу же после своего рождения?
В C++ невозможно сформулировать условие относительно сложного объекта "в целом", используя при этом стандартный набор операций, но легко можно определить значения данных-членов этого объекта. Для этого используется операция выбора компонента: if (ComplexType().real !ComplexType().imag){/*…*/}
Вот мы и узнали кое-что о свойствах объекта. Правда, объектов в условии целых два. У первого безымянного объекта мы поинтересовались значением данного-члена real, после чего он благополучно отошёл "в мир иной", у второго объекта выяснили значение данного-члена imag.
Выражения вызова функций типа void так же недопустимы в контексте условия, поскольку функции void "возвращают" пустые значения. Например,
void MyProc(); ::::: void MyProc() {/*…*/} ::::: if (MyProc()) {/*…*/} /* Здесь ошибка */ for ( ; MyProc(); ) {/*…*/} /* Здесь ошибка */ if (ComplexType()){/*…*/} /* Это тоже ошибка */
Выражение явного преобразования типа можно расположить справа от символа операции присвоения в операторе присвоения.
ComplexType MyVal = ComplexType (); ComplexType MyVal = ComplexType (25); ComplexType MyVal = (ComplexType) 25;
И опять перед нами так называемый явный вызов конструктора. Но, как сказано в справочном руководстве по C++, "явный вызов конструктора означает не то же самое, что использование того же синтаксиса для обычной функции-члена". Конструктор вызывается не для объекта класса, как другие функции-члены, а для области памяти. Для её преобразования ("превращения") в объект класса.
На самом деле, здесь конструктор вызывается дважды. В первый раз при создании переменной MyVal, второй - в ходе выполнения операции явного преобразования значения, возможно, что пустого. При этом создаётся временный безымянный объект, значения данных-членов которого присваиваются переменной MyVal. Нам ещё предстоит выяснить, как работает операция присвоения на множестве производных типов, в частности, в сочетании с выражением явного преобразования типа, которое приводит к вызову конструктора. И если можно ещё как-то представить пустое значение, которое используется для начальной инициализации данных-членов вновь создаваемого объекта, то присвоение пустого значения леводопустимому выражению в принципе невозможно. Поэтому выражение вызова функции с void спецификатором в операторе присвоения недопустимо:
int MyVal = MyProc(); /* Ошибка */ int MyVal = (void)MyProc(); /* Ошибка */
И ещё одно сравнение между конструктором и void-процедурой. Поскольку тип void - это всё же тип, мы можем объявить указатель на void-процедуру.
void MyFunction (void); ::::: void (*MyFunctionPointer) (void);
Указатель на функцию можно настроить на адрес конкретной функции. Для этого существует операция взятия адреса:
MyFunctionPointer = MyFunction; /* Можно так. */ MyFunctionPointer = MyFunction; /* А можно и так. */
С конструктором всё по-другому. Мы можем определить адрес создаваемого конструктором объекта. Всё то же выражение явного преобразования типа обеспечивает обращение к конструктору, который создаёт в памяти безымянный объект, чей адрес и определяется операцией взятия адреса: if (ComplexType()) {/*…*/}
Но вот объявить указатель на конструктор и определить адрес конструктора невозможно. Объявление указателя на функцию требует стандартной спецификации типа функции. Операция взятия адреса возвращает значение определённого типа. Конструктор же не обладает стандартной спецификацией, а потому невозможно определить для него указатель и определить соответствующее значение.
Литералы
В C++ существует четыре типа литералов:
целочисленный литерал, вещественный литерал, символьный литерал, строковый литерал.
Это особая категория слов языка. Для каждого подмножества литералов испольльзуются собственные правила словообразования. Мы не будем приводить здесь эти правила. Ограничимся лишь общим описанием структуры и назначения каждого подмножества литералов. После этого правила станут более-менее понятны.
Целочисленный литерал служит для записи целочисленных значений и является соответствующей последовательностью цифр (возможно со знаком '-'). Целочисленный литерал, начинающийся с 0, воспринимается как восьмеричное целое. В этом случае цифры 8 и 9 не должны встречаться среди составляющих литерал символов. Целочисленный литерал, начинающийся с 0x или 0X, воспринимается как шестнадцатеричное целое. В этом случае целочисленный литерал может включать символы от A или a, до F или f, которые в шестнадцатеричной системе эквивалентны десятичным значениям от 10 до 15. Непосредственно за литералом может располагаться в произвольном сочетании один или два специальных суффикса: U (или u) и L (или l). Вещественный литерал служит для отображения вещественных значений. Он фиксирует запись соответствующего значения в обычной десятичной или научной нотациях. В научной нотации мантисса отделяется от порядка литерой E или e). Непосредственно за литералом могут располагаться один из двух специальных суффиксов: F (или f) и L или l). Значением символьного литерала является соответствующее значения ASCII кода (это, разумеется, не только буквы, буквы-цифры или специальные символы алфавита C++). Символьный литерал представляет собой последовательность из одной или нескольких литер, заключённых в одинарные кавычки. Символьный литерал служит для представления литер в одном из форматов представления. Например, литера Z может быть представлена литералом 'Z', а также литералами '\132' и '\x5A'. Любая литера может быть представлена в нескольких форматах представления: обычном, восьмеричном и шестнадцатеричном. Допустимый диапазон для обозначения символьных литералов в восьмеричном представлении ограничен восьмеричными числами от 0 до 377. Допустимый диапазон для обозначения символьных литералов в шестнадцатеричном представлении ограничен шестнадцатеричными числами от 0x0 до 0xFF. Литеры, которые используются в качестве служебных символов при организации формата представления или не имеют графического представления, могут быть представлены с помощью ещё одного специального формата. Ниже приводится список литер, которые представляются в этом формате. К их числу относятся литеры, не имеющие графического представления, а также литеры, которые используются при организации структуры форматов.
Список литер организован по следующему принципу: сначала приводится представление литеры в специальном формате, затем - эквивалентное представление в шестнадцатеричном формате, далее - обозначение или название литеры, за которым приводится краткое описание реакции на литеру (смысл литеры).
\0 \x00 null пустая литера \a \x07 bel сигнал \b \ x08 bs возврат на шаг \f \x0C ff перевод страницы \n \x0A lf перевод строки \r \x0D cr возврат каретки \t \x09 ht горизонтальная табуляция \v \x0B vt вертикальная табуляция \\ \x5C \ обратная косая черта \' \x27 ' \" \x22 " \? \x3F ?
Строковые литералы являются последовательностью (возможно, пустой) литер в одном из возможных форматов представления, заключённых в двойные кавычки. Строковые литералы, расположенные последовательно, соединяются в один литерал, причём литеры соединённых строк остаются различными. Так, например, последовательность строковых литералов "\xF" "F" после объединения будет содержать две литеры, первая из которых является символьным литералом в шестнадцатеричном формате '\F', второй - символьным литералом 'F'. Строковый литерал и объединённая последовательность строковых литералов заканчиваются пустой литерой, которая используется как индикатор конца литерала.
Массив и константный указатель
Несмотря на некоторое сходство с константным указателем, массив является особым типом данных. В этом разделе мы рассмотрим основные отличия массива и константного указателя.
Прежде всего, рассмотрим варианты инициализации указателя:
char * const pcchVal_1 = chArray_2; char * const pcchVal_2 = new char[5]; char * const pcchVal_3 = (char *) malloc(5*sizeof(char));
Для инициализации последнего константного указателя был использован вызов функции 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(pciVal) " intArray:" sizeof(intArray);
Для Borland C++ 4.5, операция sizeof покажет размер области памяти, занимаемой указателем (4 байта) и размер массива (размер элемента * размерность массива)==(10 байт). Операция sizeof различает указатели и имена массивов.
Кроме того, следующий тест также даёт различные результаты.
if (intArray == intArray) cout "Yes, массив." endl; else cout "No, массив." endl; if (pciVal == pciVal) cout "Yes, указатель. " endl; else cout "No, указатель." endl;
Результат выполнения:
Yes, массив. No, указатель.
Значение указателя, представляющего массив, совпадает с адресом первого элемента массива.
Значением указателя, проинициализированного с помощью выражения размещения, является адрес начала этой области. Сам указатель как объект обладает своим собственным адресом.
Интересно, что сравнение значения указателя с результатом выполнения операции взятия адреса не является абсолютно корректным с точки зрения соответствия типов. Операция взятия адреса возвращает лишь определённое значение адреса. И при этом после выполнения этой операции как бы ничего не известно о типе операнда, чей адрес определяли с помощью этой самой операции взятия адреса. Транслятор отслеживает это нарушение принципа соответствия типов и выдаёт предупреждение "Nonportable pointer comparison".
Поскольку это всего лишь предупреждение, выполнение процесса трансляции не прерывается и загрузочный модуль, построенный на основе этого программного кода, корректно выполняется. "Успокоить" транслятор можно с помощью операции явного преобразования типа, которая отключает контроль над типами:
if (intArray == (int *)intArray) cout "Yes"; else cout "No";
Массив констант
Как уже известно, имя массива является константным указателем. Именно поэтому и невозможно копирование массивов с помощью простого оператора присвоения. Константный указатель "охраняет" область памяти, выделенную для размещения данного массива. При этом значения элементов массива можно изменять в ходе выполнения программы. Защитить их от изменения можно с помощью дополнительного спецификатора типа const. При этом массив должен быть проинициализирован непосредственно в момент определения: const int cIntArray[] = {0,1,2,3,4,5,6,7,8,9};
Это аналог константного указателя на массив констант. Попытки изменения значения элементов массива пресекаются на этапе компиляции. cIntArray[5] = 111; // Ошибка.
А вот от скрытого изменения значения элементы массива констант уберечь не удаётся.
const char cCH[] = "0123456789"; char CH[] = "0123456789"; CH[15] = 'X'; /* Выполнение этого оператора ведёт к изменению строки cCH. */ cout cCH endl;
Транслятор не занимается проверкой корректности выполняемых операций. На этапе выполнения программы язык C++ не предоставляет никаких средств защиты данных.
Массивы и параметры
В C++ возможно лишь поэлементное копирование массивов. Этим объясняется то обстоятельство, что в списке объявлений параметров не объявляются параметры-массивы. В Borland С++ 4.5 транслятор спокойно реагирует на объявление одномерного массива в заголовке функции, проверяет корректность его объявления (размеры массива должны быть представлены константными выражениями), однако сразу же игнорирует эту информацию. Объявление одномерного массива-параметра преобразуется к объявлению указателя. Подтверждением этому служит тот факт, что "массив"-параметр невозможно проинициализировать списком значений, что совершенно нормально для обычных массивов: void ff(int keyArr[ ] = {0,1,2,3,4,5,6,7,8,9});// Ошибка объявления. void ff(int keyArr[10] = {0,1,2,3,4,5,6,7,8,9});// Ошибка объявления.
Оба варианта прототипа функции будут отвергнуты. При этом транслятор утверждает, что указателю (и это несмотря на явное указание размеров массива!) можно присваивать значение адреса, либо NULL. int keyArr[100]; // Глобальный массив. int xArr[5]; // Ещё один глобальный массив. int XXX; // Простая переменная. void ff(int keyArr[ 1] = keyArr, //Объявление одноименного параметра. int pArr1 [10] = xArr, int pArr2 [ ] = XXX, // Адрес глобальной переменной. int pArr3 [ ] = xArr[10], //Адрес несуществующего элемента. int pArr4 [50] = NULL); /* Допустимые способы инициализации массивов в прототипе функции свидетельствуют о том, что здесь мы имеем дело с указателями. */
Следующий пример подтверждает тот факт, что объявление одномерного массива в списке параметров оказывается на самом деле объявлением указателя. #include iostream.h void fun(int *, int[], int qwe[10] = NULL); /* Все три объявления параметров на самом деле являются объявлениями указателей. */ void main() { int Arr[10] = {0,1,2,3,4,5,6,7,8,9}; int *pArr = Arr; /* В функции main определены массив и указатель.*/ cout Arr " " Arr " " Arr[0] endl; cout pArr " " pArr " " pArr[0] endl; /* Разница между массивом и указателем очевидна: значение выражения, представленного именем массива, собственный адрес массива и адрес первого элемента массива совпадают. */ fun(Arr, Arr, Arr); } void fun(int* pArr1, int pArr2[], int pArr3[100]) { cout sizeof(pArr1) endl; cout sizeof(pArr2) endl; cout sizeof(pArr3) endl; cout pArr1 " " pArr1 " " pArr1[0] endl; cout pArr2 " " pArr2 " " pArr2[0] endl; cout pArr3 " " pArr3 " " pArr3[0] endl; /* Все параметры проявляют свойства указателей. */ }
Так что размеры массива в объявлении параметра, подобно имени параметра в прототипе, являются лишь украшением, которое предназначается для напоминания программисту о назначении параметра.
При вызове функции передаются либо отдельные элементы массива и тогда мы имеем тривиальный список параметров, либо адреса, которые воспринимаются как адреса начальных элементов массивов. В последнем случае неизвестными оказываются размеры массива, однако, эта проблема решается благодаря введению дополнительного целочисленного параметра, задающего размеры массива, представленного указателем.
Следующий пример демонстрирует возможный вариант решения проблемы передачи в вызываемую функцию переменного количества однотипных значений. Подлежащие обработке данные в вызывающей функции располагаются в непрерывной области памяти (в нашем примере это целочисленный массив Arr). При этом обрабатывающая функция имеет два параметра, один из которых является указателем на объект обрабатываемого типа (в определении функции закомментированы альтернативные варианты объявления этого параметра), второй - целочисленного типа. В выражении вызова значением первого параметра оказывается адрес первого элемента массива, значением второго параметра - количество обрабатываемых элементов массива. Таким образом, функция с постоянным количеством параметров позволяет обрабатывать заранее неизвестное количество значений. #include iostream.h void fun(int * = NULL, int = 0); void main() { int 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++ многомерный массив - понятие условное. Как известно, массив размерности n является одномерным массивом множества объектов производного типа - массивов размерности n-1. Размерность массива является важной характеристикой производного типа. Отсюда - особенности объявления многомерных массивов как параметров функций.
В следующем примере определена функция fun с трёхмерным параметром размерности 5*5*25. Транслятор спокойно реагирует на различные варианты прототипов функции fun в начале программы. Если последовательно комментировать варианты объявлений функции, ошибка будет зафиксирована лишь тогда, когда будут закомментированы все объявления, у которых характеристика второй и третьей размерности совпадает с аналогичной характеристикой многомерного параметра-массива в определении функции. #include iostream.h #define DIM1 3 #define DIM2 5 // void fun(int rrr[][][]); /* Такой прототип неверен! Квадратные скобки в объявлении параметра, начиная со второй, обязательно должны содержать константные выражения, значения которых должны соответствовать значениям в квадратных скобках (начиная со второй!) в объявлении параметра в определении функции. Эти значения в контексте объявления параметров являются элементами спецификации ТИПА параметра, а не характеристиками его РАЗМЕРОВ. Типы составляющих одномерные массивы элементов в прототипе и заголовке определения функции должны совпадать. */ //void fun(int rrr[5][DIM1][DIM2]); void fun(int rrr[][3][5]); void fun(int rrr[15][DIM1][5]); void fun(int *rrr[3][DIM2]); /* Во всех этих случаях параметр rrr является указателем на двумерный массив из 3*5 элементов типа int. "Массив из трёх по пять элементов типа int" - такова спецификация типа объекта. */ /* Следующие два прототипа, несмотря на одно и то же имя функции, объявляют ещё пока неопределённые фунции. Одноимённые функции с различными списками параметров называются перегруженными функциями. */ void fun(int *rrr[25][250]); void fun(int rrr[50][100][DIM1]); void main() { int Arr[2][DIM1][DIM2] = { { {1 ,2 ,3 ,4 ,5 }, {10,20,30,40,50}, {11,12,13,14,15}, }, { {1,}, {2,}, {3,}, } }; fun(Arr); // Вызов fun. Значение параметра - адрес начала массива. } void fun(int pArr[75][DIM1][DIM2]) { cout sizeof(pArr) endl; cout pArr " " pArr " " pArr[0][0] endl; /* Параметр проявляет свойства указателей. */ cout sizeof(*pArr) endl; cout *pArr " " *pArr " " *pArr[0][0] endl; /* Если применить к указателю операцию разыменования, можно убедиться в том, что параметр указывает на массив. При этом о топологии многомерного массива можно судить исключительно по косвенной информации (в данном случае - по значениям константных выражений DIM1 и DIM2) или по значениям дополнительных параметров. */ }
При работе с параметрами-массивами мы имеем дело с указателями. Это немаловажное обстоятельство позволяет непосредственно из вызываемой функции изменять значения объектов, определённых в вызывающей функции.
Массивы. Синтаксис объявления
Рассмотрим новые формы Бэкуса-Наура, которые дополняют уже известные понятия описателя и инициализатора.
Объявление ::= [СписокСпецификаторовОбъявления] [СписокОписателей]; Описатель ::= Описатель [Инициализатор] Описатель ::= Описатель[[КонстантноеВыражение]] ::= ***** Инициализатор ::= = {СписокИнициализаторов [,]} СписокИнициализаторов ::= Выражение
::= СписокИнициализаторов, Выражение
::= {СписокИнициализаторов [,]}
Теперь мы располагаем набором синтаксических средств для объявления массивов. Массивы представляют собой производные типы (указатели также относятся к производным типам).
Объект типа "массив элементов заданного типа" представляет последовательность объектов этого самого типа, объединённых одним общим именем. Количество элементов массива является важной характеристикой самого массива, но не самого типа. Эта характеристика называется размерностью массива.
Приведём примеры объявления и определения массивов. extern int intArray_1[];
Объявлен (именно объявлен - об этом говорит спецификатор extern) массив типа int, имя массива - intArray_1, разделители [] указывают на то, что перед нами объявление массива. int intArray_2[10];
А это уже определение массива. Всё тот же тип int, имя массива - intArray, между разделителями [ и ] находится константное выражение, значение которого определяет размерность массива.
Требование синтаксиса по поводу константного выражения между разделителями в определении массива может быть объяснено лишь тем, что информация о количестве элементов массива требуется до момента начала выполнения программы. int intArray_3[] = {1,2,3}; // Это также определение массива.
Количество элементов массива становится известным транслятору при анализе инициализатора. Элементам массива присваиваются соответствующие значения из списка инициализаторов.
Ещё одна форма определения массива: int intArray_4[3] = {1,2,3};
В этом определении массива важно, чтобы количество элементов в инициализаторе массива не превышало значение константного выражения в описателе массива.
В результате выполнения этого оператора в памяти выделяется область, достаточная для размещения трёх объектов-представителей типа int. Участку присваивается имя intArray_4. Элементы инициализируются значениями, входящими в состав инициализатора.
Возможна частичная инициализация массива. При этом значения получают первые элементы массива: int intArray_5[3] = {1,2};
В этом определении массива означены лишь первые два элемента массива. Значение последнего элемента массива в общем случае не определено.
Здесь нужно отметить одну интересную особенность синтаксиса инициализатора массива. Речь идёт о необязательной запятой в конце списка инициализаторов. По-видимому, её назначение заключается в том, чтобы указывать на факт частичной инициализации массива.
Действительно, последний вариант (частично) инициализирующего оператора определения массива выглядит нагляднее: int intArray_5[3] = {1,2,};
Последняя запятая предупреждает о факте частичной инициализации массива. Затраты на связывание запятой в конце списка инициализаторов со строго определённым контекстом частичной инициализации оказываются столь значительными, что последняя запятая традиционно (по крайней мере со времени выхода "Справочного руководства по языку программирования C++") оказывается всего лишь необязательным элементом любой (в том числе и полной) инициализации.
int intArray_6[3] = {1,2,3}; int intArray_6[3] = {1,2,3,};// Полная инициализация с запятой… int intArray_6[] = {1,2,3}; int intArray_6[] = {1,2,3,};
Между этими операторами не существует никакой разницы.
А вот в таком контексте
int intArray_6[3] = {1,2,}; // Частичная инициализация массива из трёх элементов…
Последняя запятая в фигурных скобках - не более как полезное украшение. Что-то недосказанное таится в таком операторе присвоения… int intArray_7[];
А вот это некорректное объявление. Без спецификатора extern транслятор воспринимает это как ошибку. В скором времени мы обсудим причину этого явления.
Многомерные динамические массивы
Многомерный массив в C++ по своей сути одномерен. Операции new[] и delete[] позволяют создавать и удалять динамические массивы, поддерживая при этом иллюзию произвольной размерности. Деятельность по организации динамического массива требует дополнительного внимания, которое окупается важным преимуществом: характеристики массива (операнды операции new) могут не быть константными выражениями. Это позволяет создавать многомерные динамические массивы произвольной конфигурации. Следующий пример иллюстрирует работу с динамическими массивами. #include iostream.h int fdArr(int **, int, int); int fdArr(int ***, int, int, int); // Одноимённые функции. Различаются списками списками параметров. // Это так называемые перегруженные функции. О них позже. void main() { int i, j; /* Переменные (!) для описания характеристик массивов.*/ int dim1 = 5, dim2 = 5, dim3 = 10, wDim = dim2; /* Организация двумерного динамического массива производится в два этапа. Сначала создаётся одномерный массив указателей, а затем каждому элементу этого массива присваивается адрес одномерного массива. Для характеристик размеров массивов не требуется константных выражений. */ int **pArr = new int*[dim1]; for (i = 0; i dim1; i++) pArr[i] = new int[dim2]; pArr[3][3] = 100; cout pArr[3][3] endl; fdArr(pArr,3,3); /* Последовательное уничтожение двумерного массива… */ for (i = 0; i dim1; i++) delete[]pArr[i]; delete[]pArr; /* Организация двумерного "треугольного" динамического массива. Сначала создаётся одномерный массив указателей, а затем каждому элементу этого массива присваивается адрес одномерного массива. При этом размер (количество элементов) каждого нового массива на единицу меньше размера предыдущего. Заключённая в квадратные скобки переменная в описателе массива, которая, в данном контексте, является операндом операции new, позволяет легко сделать это. */ int **pXArr = new int*[dim1]; for (i = 0; i dim1; i++, wDim--) pXArr[i] = new int[wDim]; pXArr[3][3] = 100; cout pArr[3][3] endl; fdArr(pXArr,3,3); /* Последовательное уничтожение двумерного массива треугольной конфигурации… */ for (i = 0; i dim1; i++) delete[]pXArr[i]; delete[]pXArr; /* Создание и уничтожение трёхмерного массива требует дополнительной итерации. Однако здесь также нет ничего принципиально нового. */ int ***ppArr; ppArr = new int**[dim1]; for (i = 0; i dim1; i++) ppArr[i] = new int*[dim2]; for (i = 0; i dim1; i++) { for (j = 0; j dim2; j++) ppArr[i][j] = new int[dim3]; } ppArr[1][2][3] = 750; cout ppArr[1][2][3] endl; fdArr(ppArr,1,2,3); for (i = 0; i dim1; i++) { for (j = 0; j dim2; j++) delete[]ppArr[i][j]; } for (i = 0; i dim1; i++) delete[]ppArr[i]; delete[] ppArr; } int fdArr(int **pKey, int index1, int index2) { cout pKey[index1][index2] endl; } int fdArr(int ***pKey, int index1, int index2, int index3) { cout pKey[index1][index2][index3] endl; }
Многомерный массив
Многомерные массивы в C++ рассматриваются как массивы, элементами которых являются массивы.
Определение многомерного массива должно содержать информацию о типе, размерности и количестве элементов каждой размерности.
int MyArray1[10]; // Одномерный массив размерности 10. int MyArray2[20][10]; // 20 одномерных массивов размерности 10. int MyArray3[30][20][10]; // 30 двумерных массивов размерности 20*10.
По крайней мере, для Borland C++ 4.5, элементы многомерного массива располагаются в памяти в порядке возрастания самого правого индекса, т.е. самый младший адрес имеют элементы
MyArray1[0], MyArray2[0][0], MyArray3[0][0][0],
затем элементы
MyArray1[1], MyArray2[0][1], MyArray3[0][0][1]
и т.д.
Многомерный массив подобно одномерному массиву может быть проинициализирован с помощью списка инициализаторов. Первыми инициализируются элементы с самыми маленькими индексами: int MyArray[3][3][3] = {0,1,2,3,4,5,6,7,8,9,10,11};
Начальные значения получают следующие элементы трёхмерного массива:
MyArray[0][0][0] == 0 MyArray[0][0][1] == 1 MyArray[0][0][2] == 2 MyArray[0][1][0] == 3 MyArray[0][1][1] == 4 MyArray[0][1][2] == 5 MyArray[0][2][0] == 6 MyArray[0][2][1] == 7 MyArray[0][2][2] == 8 MyArray[1][0][0] == 9 MyArray[1][0][1] == 10 MyArray[1][0][2] == 11
Остальные элементы массива получают начальные значения в соответствии со статусом массива (в глобальном массиве значения остальных элементов равны 0, в локальном массиве элементам присваиваются неопределённые значения).
Дополнительные фигурные скобки в инициализаторе позволяют инициализировать отдельные фрагменты многомерного массива. Каждая пара фигурных скобок специфицирует значения, относящиеся к одной определённой размерности. Пустые фигурные скобки не допускаются (и это означает, что в C++ реализован жёсткий алгоритм инициализации массивов):
int MyArray[3][3][3] = { {{0,1}}, {{100},{200,210},{300}}, {{1000},{2000,2100},{3000,3100,3200}} };
В результате выполнения этого оператора определения будут означены следующие элементы массива MyArray:
MyArray[0][0][0] == 0 MyArray[0][0][1] == 1 MyArray[1][0][0] == 100 MyArray[1][1][0] == 200 MyArray[1][1][1] == 210 MyArray[1][2][0] == 300 MyArray[2][0][0] == 1000 MyArray[2][1][0] == 2000 MyArray[2][1][1] == 2100 MyArray[2][2][0] == 3000 MyArray[2][2][1] == 3100 MyArray[2][2][2] == 3200
По аналогии с одномерным массивом, при явной инициализации массива входящего в состав многомерного массива его самая левая размерность может не указываться. Она определяется на основе инициализатора.
int MyArray[ ][3][3] = { {{0,1}}, {{100},{200,210},{300}}, {{1000},{2000,2100}} };
Транслятор понимает, что речь идёт об определении массива размерности 3*3*3.
А в таком случае
int MyArray[ ][3][3] = { {{0,1}}, {{100},{200,210},{300}}, {{1000},{2000,2100}}, {{10000}} };
предполагается размерность 4*3*3. В результате MyArray оказывается массивом из четырёх частично проинициализированных двумерных массивов. Следует помнить, что в C++ нет принципиальной разницы между массивом массивов произвольной размерности и обычным одномерным массивом. Потому и простор для творчества в деле инициализации многомерных массивов ограничивается левым индексом.
Множественное наследование
В C++ производный класс может быть порождён из любого числа непосредственных базовых классов. Наличие у производного класса более чем одного непосредственного базового класса называется множественным наследием. Синтаксически множественное наследование отличается от единичного наследования списком баз, состоящим более чем из одного элемента.
class A { }; class B { }; class C : public A, public B { };
При создании объектов-представителей производного класса, порядок расположения непосредственных базовых классов в списке баз определяет очерёдность вызова конструкторов умолчания.
Этот порядок влияет и на очерёдность вызова деструкторов при уничтожении этих объектов. Но эти проблемы, также как и алгоритмы выделения памяти для базовых объектов, скорее всего, относятся к вопросам реализации. Вряд ли программист должен акцентировать на этом особое внимание.
Более существенным является ограничение, согласно которому одно и то же имя класса не может входить более одного раза в список баз при объявлении производного класса. Это означает, что в наборе непосредственных базовых классов, которые участвуют в формировании производного класса не должно встречаться повторяющихся элементов.
Вместе с тем, один и тот же класс может участвовать в формировании нескольких (а может быть и всех) непосредственных базовых классов данного производного класса. Так что для непрямых базовых классов, участвующих в формировании производного класса не существует никаких ограничений на количество вхождений в объявление производного класса:
class A { public: int x0, xA; }; class B : public A { public: int xB; }; class C : public A { public: int x0, xC; }; class D : public B, public C { public: int x0, xD; };
В этом примере класс A дважды используется при объявлении класса D в качестве непрямого базового класса.
Для наглядного представления структуры производного класса также используются направленные ациклические графы, схемы классов и объектов.
Как и раньше, самый нижний узел направленного ациклического графа, а также нижний уровень схем соответствует производному классу и фрагменту объекта, представляющего производный класс.
Такой фрагмент объекта мы будем называть производным фрагментом-представителем данного класса.
Верхние узлы графа и верхние уровни схем классов и объектов соответствуют базовым классам и фрагментам объектов, представляющих базовые и непосредственные базовые классы.
Эти фрагменты объекта мы будем называть базовыми и непосредственными базовыми фрагментами-представителями класса.
Вот как выглядит граф ранее приведённого в качестве примера производного класса D:
A A B C D
А вот как представляется структура производного класса в виде неполной схемы класса. Базовые классы располагаются на этой схеме в порядке, который соответствует списку базовых элементов в описании базы производного класса. Этот же порядок будет использован при изображении диаграмм объектов. И это несмотря на то обстоятельство, что порядок вызова конструкторов базовых классов определяется конкретной реализацией. За порядком вызова конструкторов базовых классов всегда можно наблюдать после определения их собственных версий.
A B A C D
А вот и схема объекта производного класса.
D MyD; MyD ::= A (int)x0; (int)xA; B (int)xB; A (int)x0; (int)xA; C (int)x0; D (int)x0; (int)xD;
Первое, что бросается в глаза - это множество одноимённых переменных, "разбросанных" по базовым фрагментам объекта. Да и самих базовых фрагментов здесь немало.
Очевидно, что образующие объект базовые фрагменты-представители одного базового класса, по своей структуре неразличимы между собой. Несмотря на свою идентичность, все они обладают индивидуальной характеристикой - положением относительно производного фрагмента объекта.
При множественном наследовании актуальной становится проблема неоднозначности, связанная с доступом к членам базовых классов. Доступ к члену базового класса является неоднозначным, если выражение доступа именует более одной функции, объекта (данные-члены класса также являются объектами), типа (об этом позже!) или перечислителя.
Например, неоднозначность содержится в следующем операторе: MyD.xA = 100;
здесь предпринимается неудачная попытка изменения значения данного-члена базового фрагмента объекта MyD. Выражение доступа MyD.xA именует сразу две переменных xA. Разрешение неоднозначности сводится к построению такого выражения доступа, которое однозначно указывало бы функцию, объект, тип (об этом позже!) или перечислитель.
Наша очередная задача сводится к описанию однозначных способов доступа к данным-членам класса, расположенным в разных базовых фрагментах объекта. И здесь мы впервые сталкиваемся с ограниченными возможностями операции доступа. MyD.B::x0 = 100;
Этот оператор обеспечивает изменение значения данного-члена базового фрагмента - представителя класса B. Здесь нет никаких проблем, поскольку непосредственный базовый класс B наследует данные-члены базового класса A. Поскольку в классе B отсутствуют данные-члены с именем x0, транслятор однозначно определяет принадлежность этого элемента. Итак, доступ к данному-члену базового класса A "со стороны" непосредственного базового класса B не представляет особых проблем. MyD.C::x0 = 100;
А теперь изменяется значение данного-члена базового фрагмента - представителя класса С. И опять же транслятор однозначно определяет местоположение изменяемой переменной. Переменная x0 была объявлена в непосредственном базовом классе C. И операция доступа указывает на эту переменную. А вот попытка изменения значения переменной x0, расположенной базовом фрагменте-представителе класса A "со стороны" непосредственного базового класса C обречена. Так, оператор MyD.A::x0 = 777;
некорректен по причине неоднозначности соотнесения класса и его члена, поскольку непонятно, о каком базовом фрагменте-представителе класса A идёт речь. Выражения доступа с составными квалифицированными именами, как например, MyD.C::A::x0
в контексте нашей программы также некорректны: составное квалифицированное имя предполагает вложенное объявление класса. Это свойство операции доступа уже обсуждалось ранее, в разделах, непосредственно посвящённых операциям. Вложенные объявления будут рассмотрены ниже.
Операция :: оставляет в "мёртвой зоне" целые фрагменты объектов. Однако возможность доступа к членам класса, которые оказались вне пределов досягаемости операции доступа всё же существует. Она обеспечивается указателями и операциями явного преобразования типа.
Идея состоит в том, чтобы, объявив указатель на объект-представитель базового класса, попытаться его настроить с помощью операций явного преобразования типа на соответствующий фрагмент объекта производного класса. В результате недосягаемые с помощью операции доступа фрагменты объекта превращаются в безымянные объекты простой конфигурации. Доступ к их членам в этом случае обеспечивается обычными операциями косвенного обращения. Рассмотрим несколько строк, которые демонстрируют такую технику работы с недосягаемыми фрагментами.
A* pObjA; B* pObjB; C* pObjC; D* pObjD = MyD; // Мы начинаем с объявления соответствующих указателей. pObjC = (C*)MyD; pObjA = (A*)pObjC; // Произведена настройка указателей на требуемые фрагменты. pObjA-x0 = 999; // А это уже элементарно!
Очевидно, что можно обойтись без поэтапных преобразований и воспользоваться свойством коммутативности операции явного преобразования типа:
((A*)(C*)pObjD)-x0 = 5; ((A*)(B*)pObjD)-x0 = 55; // Разным фрагментам - разные значения.
Аналогичным образом обстоят дела с функциями-членами базовых классов. Этот раздел мы завершаем небольшой программой, демонстрирующей методы доступа к членам базовых фрагментов объекта производного класса.
#include iostream.h class A { public: int x0; int Fun1(int key); }; int A::Fun1(int key) { cout " Fun1( " key " ) from A " endl; cout " x0 == " x0 "..." endl; return 0; } class B: public A { public: int x0; int Fun1(int key); int Fun2(int key); }; int B::Fun1(int key) { cout " Fun1( " key " ) from B " endl; cout " x0 == " x0 "..." endl; return 0; } int B::Fun2(int key) { Fun1(key * 5); cout " Fun2( " key " ) from B " endl; cout " x0 == " x0 "..." endl; return 0; } class C: public A { public: int x0; int Fun2(int key); }; int C::Fun2(int key) { A::x0 = 25; Fun1(key * 5); cout " Fun2( " key " ) from C " endl; cout " x0 == " x0 "..." endl; return 0; } class D: public B, public C { public: int x0; int Fun1(int key); }; int D::Fun1(int key) { cout " Fun1( " key " ) from D " endl; cout " x0 == " x0 "..." endl; return 0; } void main () { D MyD; ObjD.x0 = 111; A* pObjA; B* pObjB; C* pObjC; D* pObjD = MyD; MyD.B::x0 = 100; MyD.C::x0 = 333; MyD.Fun1(1); pObjD-B::Fun1(1); pObjD-C::Fun2(1); pObjA = (A*) (B*) pObjD; ((A*) ((C*) pObjD))-Fun1(111); ((A*) ((B*) pObjD))-Fun1(111); pObjA-Fun1(111); pObjC = (C*)MyD; pObjA = (A*)pObjC; ((A*)(B*)pObjD)-x0 = 1; ((A*)(B*)pObjD)-Fun1(777); ((A*)(C*)pObjD)-x0 = 2; ((A*)(C*)pObjD)-Fun1(999); }
Мультипликативные выражения
МультипликативноеВыражение ::= pmВыражение
::= МультипликативноеВыражение * pmВыражение
::= МультипликативноеВыражение / pmВыражение
::= МультипликативноеВыражение % pmВыражение
Наследование
Наследование - один из основополагающих принципов объектно-ориентированного программирования. Под наследованием понимают возможность объявления производных типов на основе ранее объявленных типов. Как известно, в C++ существует фиксированное множество элементарных типов. Это абсолютно независимые типы и объявление одного элементарного типа на основе другого в принципе невозможно.
Спецификации объявления unsigned int или long double нельзя рассматривать как модификации элементарных типов int и double. Это полноправные элементарные типы данных со своим собственным набором свойств. В C++ также невозможно определить одну функцию на основе другой ранее определённой (правда, в C++ существует понятие шаблона функции, и мы обязательно обратимся к этому вопросу).
И вот, наконец, для класса, в C++ реализуется возможность наследования. Прежде всего, следует различать наследование и встраивание. Встраивание предполагает возможность объявления в классе отдельных членов класса на основе ранее объявленных классов. В классе можно объявлять как данные-члены основных типов, так и данные-члены ранее объявленных производных типов.
В случае же наследования новый класс в буквальном смысле создаётся на основе ранее объявленного класса, НАСЛЕДУЕТ, а возможно и модифицирует его данные и функции. Объявленный класс может служить основой (базовым классом) для новых производных классов. Производный класс наследуют данные и функции своих базовых классов и добавляют собственные компоненты.
В C++ количество непосредственных "предков" производного класса не ограничено. Класс может быть порождён от одного или более классов. В последнем случае говорят о множественном наследовании. Наследование в C++ реализовано таким образом, что наследуемые компоненты не перемещаются в производный класс, а остаются в базовом классе. Производный класс может переопределять и доопределять функции-члены базовых классов. Но при всей сложности, наследование в C++ подчиняется формальным правилам. А это означает, что, во-первых, существует фиксированный набор алгоритмов, которые позволяют транслятору однозначно различать базовые и производные компоненты классов, а во-вторых, множество вариантов наследования ограничено.
Объединения
Мы возвращаемся к объединениям. Наш уровень знаний делает знакомство с этой конструкцией приятным и лёгким.
Объединение можно представить в виде структуры, размер которой достаточен для сохранения не более одного данного-члена объединения. Это конструкция для экономии памяти. Здесь может быть ничего лишнего. Отсюда его основные свойства и особенности:
объединение может включать функции-члены, в том числе конструкторы и деструкторы. Они, безусловно, могут быть полезны для обслуживания единственного значения объекта-представителя объединения; объединение не может иметь базовых классов и само также не может служить базовым классом. По этой причине в объединения не могут входить виртуальные функции (они бесполезны); объединения также не могут включать статические данные-члены, объекты-представители некоторого класса со специально объявленными конструкторами, деструкторами, операторными функциями присваивания. Всё это служит помехой компактному сохранению значений.
Неименованное объединение определяет объект, а не объявляет тип. Имена членов безымянного объединения должны отличаться от других имён из области действия, где было объявлено это объединение. Безымянное объединение не содержит объявления функций-членов.
В своей области действия имена членов объединения используются непосредственно без обычных операций обращения. Глобальное безымянное объединение объявляется как статическое. Всякий раз это всего лишь универсальный многофункциональный "контейнер" для хранения значений различных типов в одной и той же области памяти.
Этот раздел мы завершим примером, который раскрывает неожиданные возможности использования объединения. Сочетание объединения с битовым полем позволит нам убедиться в корректности преобразований дробной части вещественного числа. Здесь самое время обратиться к соответствующему приложению.
#include iostream.h union { float floatVal; struct { int bit0 : 1; int bit1 : 1; int bit2 : 1; int bit3 : 1; int bit4 : 1; int bit5 : 1; int bit6 : 1; int bit7 : 1; int bit8 : 1; int bit9 : 1; int bit10 : 1; int bit11 : 1; int bit12 : 1; int bit13 : 1; int bit14 : 1; int bit15 : 1; int bit16 : 1; int bit17 : 1; int bit18 : 1; int bit19 : 1; int bit20 : 1; int bit21 : 1; int bit22 : 1; int bit23 : 1; int bit24 : 1; int bit25 : 1; int bit26 : 1; int bit27 : 1; int bit28 : 1; int bit29 : 1; int bit30 : 1; int bit31 : 1; } BitField; } MyUnion; void main () { MyUnion.BitField.bit31 = 0; MyUnion.BitField.bit30 = 1; MyUnion.BitField.bit29 = 0; MyUnion.BitField.bit28 = 0; MyUnion.BitField.bit27 = 0; MyUnion.BitField.bit26 = 0; MyUnion.BitField.bit25 = 1; MyUnion.BitField.bit24 = 1; MyUnion.BitField.bit23 = 0; MyUnion.BitField.bit22 = 0; MyUnion.BitField.bit21 = 1; MyUnion.BitField.bit20 = 1; MyUnion.BitField.bit19 = 0; MyUnion.BitField.bit18 = 0; MyUnion.BitField.bit17 = 1; MyUnion.BitField.bit16 = 0; MyUnion.BitField.bit15 = 0; MyUnion.BitField.bit14 = 0; MyUnion.BitField.bit13 = 1; MyUnion.BitField.bit12 = 0; MyUnion.BitField.bit11 = 0; MyUnion.BitField.bit10 = 0; MyUnion.BitField.bit9 = 0; MyUnion.BitField.bit8 = 0; MyUnion.BitField.bit7 = 0; MyUnion.BitField.bit6 = 0; MyUnion.BitField.bit5 = 0; MyUnion.BitField.bit4 = 0; MyUnion.BitField.bit3 = 0; MyUnion.BitField.bit2 = 0; MyUnion.BitField.bit1 = 0; MyUnion.BitField.bit0 = 0; cout MyUnion.floatVal endl; }
Объекты и функции
Объектом называют область памяти, выделяемую для сохранения какой-либо информации. Эта информация в данной области памяти кодируется двоичной последовательностью. Такие последовательности составляют множество значений объекта.
Резервирование области памяти предполагает обязательную возможность доступа к ней. Обращение к объекту обеспечивается выражениями. Выражение в языке программирования является единственным средством взаимодействия с объектами. Частным случаем выражения является имя объекта.
Объекты, которые используются исключительно для сохранения информации, называются константами. Обычно константе присваивается значение в момент создания объекта. Дальнейшие изменения значения константы не допускаются.
Объекты, которые допускают изменение зафиксированных в них значений, называются переменными. Инициализация переменной (присваивание ей начального значения) может быть не связана с оределением этой переменной. Переменная открыта для изменения значений, а потому присвоение значения может быть произведено в любом месте программы, где только существует возможность доступа к переменной.
Основными характеристиками объекта являются: тип, класс памяти, область действия связанного с объектом имени, видимость имени объекта, время жизни, тип компоновки (или тип связывания).
Все атрибуты объектов в программе взаимосвязаны. Они могут быть явным образом специфицированы в программе, а могут быть заданы по умолчанию в зависимости от контекста, в котором имя объекта встречается в тексте программы.
Область памяти, выделяемая для сохранения программного кода, называется функцией. Между объектами и функциями много общего. Обращение к функциям также обеспечивается выражениями. Эти выражения называются выражениями вызова функций. Значения выражений вызова вычисляются в результате выполнения соответствующего программного кода. Функция характеризуется типом, область действия связанного с функцией имени, видимостью имени функции, типом связывания.
Объявление переменных
Мы приступаем к изучению синтаксиса операторов C++. В языке различают несколько типов операторов. Каждый из них выполняет в программе строго определённые функции.
Так, операторы объявления служат для ввода имён в программу. Процедура ввода имени переменной предполагает не только создание отличного от любого ключевого слова идентификатора, но и кодирование дополнительной информации о характеристиках объекта, с которым будет связано объявляемое имя.
К характеристикам объекта относятся тип объекта, класс памяти, время жизни объекта, множество других свойств, представляемых различными модификаторами.
Прежде чем приступить к описанию грамматики объявления переменных, введём для употребления в БНФ пару новых символов: [ и ]. Эти символы мы будем называть синтаксическими скобками. Заключение какого либо символа БНФ в синтаксические скобки означает, что этот символ в данной БНФ, а значит и в описываемом выражении является необязательным элементом. Он может входить в выражение, а может и не появляться вовсе.
Договоримся также об использовании в БНФ ещё одного символа. Этот символ будет иметь вид последовательности из пяти звёздочек, стоящих непосредственно за символом ::= в левой части формулы. Таким образом, содержащая этот символ БНФ будет выглядеть так: Описатель ::= *****
или даже так: ::= *****
Этот символ мы будем называть прерывателем БНФ. Он будет означать, что определение нетерминального символа прерывается и будет продолжено позже.
Оператор ::= ОператорОбъявления
::= *****
ОператорОбъявления ::= Объявление
Объявление ::= ОбъявлениеПеременной
::= *****
ОбъявлениеПеременной ::= ОбъявлениеПеременнойОсновногоТипа
::= *****
ОбъявлениеПеременнойОсновногоТипа ::= [СписокСпецификаторовОбъявления] [СписокОписателей];
СписокСпецификаторовОбъявления ::= [СписокСпецификаторовОбъявления] СпецификаторОбъявления
СпецификаторОбъявления ::= СпецификаторКлассаПамяти
::= СпецификаторТипа
::= cvСпецификатор
::= fctСпецификатор
::= *****
СпецификаторКлассаПамяти ::= auto ::= register ::= static ::= extern
СпецификаторТипа ::= ИмяПростогоТипа
::= СпецификаторПеречисления
::= СпецификаторКласса
::= УточнённыйСпецификаторТипа
УточнённыйСпецификаторТипа ::= КлючевоеСловоКласса Идентификатор
::= КлючевоеСловоКласса ИмяКласса
::= enum ИмяПеречисления
ИмяПростогоТипа ::= char ::= short ::= int ::= long ::= signed ::= unsigned ::= float ::= double ::= void ::= ******
cvСпецификатор ::= const ::= volatile
СписокОписателей ::= ОписательИнициализатор
::= СписокОписателей , ОписательИнициализатор
ОписательИнициализатор ::= Описатель [Инициализатор]
Описатель ::= dИмя
::= (Описатель) ::= *****
Инициализатор ::= = Выражение
::= (СписокВыражений) ::= *****
Выражение ::= Литерал ::= Имя
::= *****
СписокВыражений ::= ВыражениеПрисваивания
::= СписокВыражений , ВыражениеПрисваивания
dИмя ::= Имя
::= ИмяКласса
::= ~ ИмяКласса
::= ОписанноеИмяТипа
::= КвалифицированноеИмяТипа
ВыражениеПрисваивания - этот нетерминальный символ используется в Справочном руководстве по C++ для обозначения элементов списка выражений. Не следует особо смущаться по поводу этого нового обозначения. Это всего лишь частный случай выражения.
dИмя - это имя того, что описывается описателем в данном объявлении. В "Справочном руководстве по языку программирования C++" английский эквивалент понятия описатель - declarator. Обилие нетерминальных символов, производных от символа Имя не должно вызывать никаких затруднений. В конечном счёте, нетерминальные символы ИмяКласса , ОписанноеИмяТипа , ИмяПеречисления (об этом позже) - являются обыкновенными идентификаторами. Всё зависит от контекста объявления. Что объявляется, так и называется. Именующее класс ОписанноеИмяТипа одновременно является и ИменемКласса .
ИмяКласса ::= Идентификатор ОписанноеИмяТипа ::= Идентификатор ИмяПеречисления::= Идентификатор
Мы располагаем достаточно большим (хотя пока и неполным) множеством БНФ, которые задают правила построения синтаксически безупречных операторов объявления переменных в C++.
Согласно приведённым правилам, оператором объявления переменных будет считаться пустой оператор ;
он состоит из точки с запятой. Между прочим, точкой с запятой заканчиваются все операторы C++. Оператором объявления будет также считаться и такая последовательность спецификаторов объявления: auto register static extern char short int const;
и здесь также важно не забыть поставить точку с запятой. С точки зрения синтаксиса это правильное предложение.
Язык программировани C++ позволяет описывать данные и алгоритмы их обработки. Вместе с тем, правильно построенная цепочка слов языка может быть абсолютно бессмысленной, то есть не нести никакой информации ни о данных, ни о шагах конкретного алгоритма. Большая часть порождаемых с помощью грамматических правил предложений оказывается семантически некорректными и лишёнными всякого смысла.
Грамматика не отвечает за семантику и тем более за смысл предложений. Она всего лишь описывает правила построения операторов. Тем не менее, транслятор обеспечивает частичный семантический контроль предложений. Поэтому ранее рассмотренные объявления и воспринимаются как ошибочные. Также ошибочным оказывается объявление, состоящее из одного спецификатора объявления: int ;
Можно было бы усовершенствовать систему обозначений, которая применяется в БНФ, а заодно и сделать более строгими правила синтаксиса. Например, можно было бы добиться того, чтобы пустые операторы воспринимались как синтаксически некорректные предложения. Однако это не может привести к кардинальному решению проблемы семантического контроля.
Обзор принципов объектно-ориентированного программирования
Мы уже многое повидали.
Основные типы с их свойствами и перечнем операций, леводопустимые выражения, операторы управления, массивы и указатели, указатели на функции и функции, использующие указатели на функции как параметры и возвращаемые значения, совместно используемые (перегруженные) функции и алгоритм сопоставления…
При объявлении классов мы знаем, в чём состоят различия между статическими и нестатическими членами, нам известно назначение деструкторов и особенности различных вариантов конструкторов.
Мы познакомились с принципом наследования - одним из трёх основных принципов объектно-ориентированного программирования. Он реализуется через механизмы наследования и виртуальных классов, которые позволяют строить новые производные классы на основе ранее объявленных базовых классов. Принцип наследования уподобил процесс программирования процессу сборки сложных устройств и механизмов из наборов стандартных узлов и деталей.
Принцип инкапсуляции - второй принцип объектно-ориентированного программирования, делает процесс программирования ещё более похожим на работу в сборочном цехе. Хорошо спроектированный класс имеет открытый интерфейс для взаимодействия с "внешним миром" и защищённую от случайного воздействия "внутреннюю" часть. Такой класс подобен автомобильному двигателю. В момент его установки в кузове или на раме при сборке автомобиля, он уже полностью собран. И не нужно сверлить в корпусе двигателя дополнительные отверстия для подсоединения трубопроводов системы охлаждения, подачи топлива и машинного масла. Разделение класса на скрытую внутреннюю часть и открытый интерфейс обеспечивается системой управления доступом к компонентам класса и дружественными функциями.
Принцип полиморфизма (полиморфизм означает буквально многообразие форм) - ещё один принцип объектно-ориентированного программирования. Он заключается в способности объекта во время выполнения программы динамически изменять свои свойства. Возможность настройки указателя на объект базового класса на объекты производных классов и механизм виртуальных функций лежат в основе этого принципа объектно-ориентированного программирования.
Многое нам ещё предстоит узнать, но то главное, что собственно и делает C++ объектно-ориентированным языком, уже известно.
Операция ##. Конкатенация в макроопределениях
В следующем примере мы используем ещё одну специальную операцию для упревления препроцессором - операцию конкатенации ##. Обычно эта операция используется в контексте функциональных макроопределений.
Если в замещающей последовательности лексем между двумя лексемами встречается операция препоцессирования ## и, возможно, что одна или обе лексемы являются параметрами, то препроцессор вначале обеспечивает подстановку фактических значений в параметры, после чего сама операция конкатенации и окружающие её пробельные символы убираются: #include iostream.h #define XXX(YYY) "МАРТИНИ со льдом и" ## YYY void main() { cout XXX("ВИСКИ с содовой") endl; cout XXX("на сегодня достаточно…") endl; // Для препроцессора тип параметра не имеет значения. // Важно, как посмотрит на это транслятор… cout XXX(255); }
Перед нами ещё одно мощное и изящное средство управления препроцессором.
Оператор return. Точка вызова и точка возврата
Нам уже известна операция вызова функции и синтаксис постфиксного выражения, обеспечивающего ввызов. Можно довольно просто представить внешний вид оператора вызова функции. Это оператор-выражение произвольной сложности, в состав которого входит выражение вызова функции. Любое выражение имеет значение и тип. Значение выражения вычисляется в ходе выполнения соответствующего программного кода.
Для каждого выражения существует момент начала вычисления значения. Этот момент характеризуется соответствующими значениями регистров процессора и состоянием памяти компьютера. Это обстоятельство позволяет определить гипотетическую точку начала выполнения выражения. На листинге программы эта точка располагается обычно слева, но она может быть расположена и справа от соответствующего выражения. Расположение этой точки зависит от многих обстоятельств. В том числе, от приоритета выполняемых операций и от порядка вычисления выражения, который зависит от входящей в выражение операции.
Мы можем указать эту точку на листинге программы лишь благодаря тому обстоятельству, что транслятор обеспечивает строгое функциональное соответствие множества команд ассемблера и программного кода.
Точка завершения выполнения выражения соответствует моменту завершения вычисления значения и на листинге программы располагается справа или соответственно слева от вычисляемого выражения. В точке завершения становится известно значение выражения.
Если выражение является выражением вызова функции, точка завершения выполнения выражения называется точкой возврата из функции.
Так вот оператор return немедленно прекращает выполнение операторов в теле функции и передаёт управление в точку возврата. Поскольку вызов функции является выражением, точка возврата имеет значение. Это значение определяется значением выражения, которое обычно располагается непосредственно за оператором возврата return. Тип возвращаемого значения должен соответствовать типу, который указывается спецификатором определения в объявлении и определении функции.
Если в качестве спецификатора объявления в определении и объявлении функции используется ключевое слово void, оператор return в теле этой функции используется без выражения. В этом случае выражение вызова функции оказывается выражением типа void, а значение выражения вызова в точке возврата оказывается неопределённым. Такое выражение не может входить в состав выражений более сложной структуры в качестве операнда выражения, поскольку значение всего выражения оказывается неопределённым.
Выражение с неопределённым значением (выражение вызова функции типа void) может выступать лишь в качестве выражения-оператора. Главное - это не забыть поставить в конце этого выражения разделитель ';', который и превращает это выражение в оператор.
Операторы C++
Согласно принятой нами терминологии, любое законченное предложение на языке C++ называется оператором. Рассмотрим множество БНФ, определяющих синтаксис операторов.
Оператор ::= ОператорВыражение
::= Объявление
::= СоставнойОператор
::= ПомеченныйОператор
::= ОператорПерехода
::= ВыбирающийОператор
::= ОператорЦикла
ОператорВыражение ::= [Выражение];
Судя по последней форме Бэкуса-Наура, любое правильно построенное выражение (построенное по правилам грамматики), заканчивающееся точкой с запятой, является оператором C++.
Мы уже второй раз сталкиваемся с пустым оператором (достаточно внимательно посмотреть на последнюю форму Бэкуса-Наура). Первый раз мы встретили пустой оператор при анализе объявления. Ничего удивительного. Объявление - это тоже оператор.
Пустой оператор имеет особое назначение. Он используется везде, где по правилам синтаксиса обязательно требуются операторы, а по алгоритму раазрабатываемой программы не нужно ни одного. Эти ситуации подробно будут рассмотрены ниже.
Оператор объявления мы уже рассмотрели ранее. На очереди - составной оператор. СоставнойОператор ::= {[СписокОператоров]}
Что такое список операторов - также уже известно. Судя по последней БНФ, составной оператор (даже пустоой) всегда начинается разделителем { и завершается разделителем }. Кроме того, составной оператор может быть абсолютно пустым (между двумя фигурными скобками может вообще ничего не стоять).
Так что конструкция {};
однозначно воспринимается транслятором как последовательность, состоящая из двух операторов - пустого составного оператора и простого пустого.
Операторы цикла
Операторы цикла задают многократное исполнение.
ОператорЦикла ::= while (Выражение) Оператор
::= for (ОператорИнициализацииFor [Выражение] ; [Выражение] )Оператор
::= do Оператор while (Выражение);
ОператорИнициализацииFor ::= ОператорВыражение
::= Объявление
Прежде всего, отметим эквивалентные формы операторов цикла.
Оператор for (ОператорИнициализацииFor [ВыражениеA] ;[ВыражениеB]) Оператор
эквивалентен оператору
ОператорИнициализацииFor while (ВыражениеA) { Оператор
ВыражениеB ; }
Эти операторы называются операторами с предусловием.
Здесь следует обратить внимание на точку с запятой после выражения в теле оператора цикла while. Здесь выражение становится оператором.
А вот условие продолжения цикла в операторе цикла while опускать нельзя. В крайнем случае, это условие может быть представлено целочисленным ненулевым литералом.
Следует также обратить внимание на точку с запятой между двумя выражениями цикла for. В последнем примере они представлены символами ВыражениеA и ВыражениеB. Перед нами классический пример разделителя.
ОператорИнициализацииFor является обязательным элементом заголовка цикла. Обязательный оператор вполне может быть пустым.
Рассмотрим пример оператора цикла for: for ( ; ; ) ;
Его заголовок состоит из пустого оператора (ему соответствует первая точка с запятой) и разделителя, который разделяет два пустых выражения. Тело цикла - пустой оператор.
Пустое выражение, определяющее условие выполнения цикла for интерпретируется как всегда истинное условие. Отсутствие условия выполнения предполагает безусловное выполнение.
Синтаксис C++ накладывает на структуру нетерминального символа ОператорИнициализацииFor жёсткие ограничения:
это всегда единственный оператор, он не может быть блоком операторов, единственным средством усложнения его структуры служит операция запятая.
Эта операция управляет последовательностью выполнения образующих оператор выражений.
Рассмотрим принципы работы этого оператора. Цикл состоит из четырёх этапов.
Прежде всего, выполняется оператор инициализации цикла. Если он не пустой, выражение за выражением, слева направо. Этот этап можно назвать этапом инициализации цикла. Он выполняется один раз, в самом начале работы цикла. Затем вычисляется значение выражения, которое располагается слева от оператора инициализации. Это выражение называется выражением условия продолжения цикла. Сам этап можно назвать этапом определения условий выполнимости. Если значение этого выражения отлично от нуля (т.е. истинно), выполняется оператор цикла. Этот этап можно назвать этапом выполнения тела цикла. После этого вычисляются значения выражений, которые располагаются слева от выражения условия продолжения цикла. Этот этап можно назвать этапом вычисления шага цикла. На последних двух этапах могут измениться значения ранее определённых переменных. А потому следующий цикл повторяется с этапа определения условий выполнимости.
Оператор инициализации цикла - это всего лишь название оператора, который располагается в заголовке цикла. Этот оператор может инициализировать переменные, если того требует алгоритм, в этот оператор могут входить любые выражения, в конце концов, он может быть пустым. Транслятору важен синтаксис оператора, а не то, как будет выполняться данный оператор цикла.
int qwe; for (qwe 10; ; ) {} // Оператор инициализатор построен на основе выражения сравнения. for (this; ; ) {} // Оператор инициализатор образован первичным выражением this. for (qwe; ; ) {} // Оператор инициализатор образован первичным выражением qwe. Ещё пример: int i = 0; int j; int val1 = 0; int val2; ::::: i = 25; j = i*2; ::::: for ( ; i 100; i++, j--) { val1 = i; val2 - j; }
Мы имеем оператор цикла for, оператор инициализации которого пуст, а условие выполнения цикла основывается на значении переменной, которая была ранее объявлена и проинициализирована. Заголовок цикла является центром управления цикла. Управление циклом основывается на внешней по отношению к телу цикла информации.
Ещё пример:
for ( int i = 25, int j = i*2; i 100; i++, j--) { val1 = i; val2 - j; }
Заголовок нового оператора содержит пару выражений, связанных операцией запятая. Тело оператора представляет всё тот же блок операторов. Что может содержать тело оператора? Любые операторы. Всё, что может называться операторами. От самого простого пустого оператора, до блоков операторов произвольной сложности! Этот блок живёт по своим законам. В нём можно объявлять переменные и константы, а поскольку в нём определена собственная область действия имён, то объявленные в блоке переменные и константы могут скрывать одноимённые объекты с более широкой областью действия имён.
А вот использование блока в операторе инициализации привело бы к дополнительным трудноразрешимым проблемам с новыми областями действия и видимости имён, вводимых в операторе инициализации. Часть переменных могла бы оказаться невидимой в теле оператора цикла.
Операция запятая позволяет в единственном операторе сделать всё то, для чего обычно используется блок операторов. В качестве составных элементов (в буквальном смысле выражений-операторов) этого оператора могут использоваться даже объявления. Таким образом, в заголовке оператора цикла for можно объявлять и определять переменные.
Рассмотрим несколько примеров. Так, в ходе выполнения оператора цикла
int i; for (i = 0; i 10; i++) { int j = 0; j += i; }
десять раз будет выполняться оператор определения переменной j. Каждый раз это будут новые объекты. Каждый раз новой переменной заново будет присваиваться новое значение одной и той же переменной i, объявленной непосредственно перед оператором цикла for.
Объявление переменной i можно расположить непосредственно в теле оператора-инициализатора цикла:
for (int i = 0; i 10; i++) { int j = 0; j += i; }
И здесь возникает одна проблема. Дело в том, что тело оператора цикла for (оператор или блок операторов) имеет ограниченную область действия имён. А область действия имени, объявленного в операторе-инициализаторе, оказывается шире этой области.
Заголовок цикла for в C++ - центр управления циклом. Здесь следят за внешним миром, за тем, что происходит вне цикла. И потому все обращения к переменным и даже их новые объявления в заголовке цикла относятся к "внешней" области видимости. Следствием такого допущения (его преимущества далеко не очевидны) является правило соотнесения имени, объявленного в заголовке и области его действия.
По отношению к объявлению переменной в заголовке оператора цикла for, правило соотнесения гласит, что область действия имени, объявленного в операторе инициализации цикла for, располагается в блоке, содержащем данный оператор цикла for.
А вот область действия имени переменной j при этом остаётся прежней.
В теле оператора for может быть определена одноимённая переменная:
for (int i = 0; i 10; i++) { int 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++, k--) { // Операторы второго цикла. }
Всё дело в том, что, согласно правилу соотнесения имён и областей действия имён в операторе цикла for, объявления переменных в заголовке цикла оказываются в общем пространстве имён. А почему, собственно, не приписать переменные, объявленные в заголовке цикла блоку, составляющему тело цикла? У каждого из альтернативных вариантов соотнесения имеются свои достоинства и недостатки. Однако выбор сделан, что неизбежно ведёт к конфликту имён и воспринимается как попытка переобъявления ранее объявленной переменной.
Эту самую пару операторов for можно переписать, например, следующим образом:
for (int i = 0, int j = 0; i 100; i++, j--) { // Здесь располагаются операторы первого цикла. } for (i = 0, int k = 250; i 100; i++, k--) { // Здесь располагаются операторы второго цикла. }
Здесь нет ошибок, но при чтении программы может потребоваться дополнительное время для того, чтобы понять, откуда берётся имя для выражения присвоения i = 0 во втором операторе цикла. Кроме того, если предположить, что операторы цикла в данном контексте реализуют независимые шаги какого-либо алгоритма, то почему попытка перемены мест пары абсолютно независимых операторов сопровождается сообщением об ошибке:
for (i = 0, int k = 250; i 100; i++, k--) { // Здесь располагаются операторы второго цикла. } for (int i = 0, int j = 0; i 100; i++, j--) { // Здесь располагаются операторы первого цикла. }
Очевидно, что в первом операторе оказывается необъявленной переменная i. Возможно, что не очень удобно, однако, в противном случае, в центре управления циклом трудно буден следить за внешними событиями. В конце концов, никто не заставляет программиста располагать в операторе инициализации объявления переменных. Исходная пара операторов может быть с успехом переписана следующим образом:
int i, j, k; ::::: for (i = 0, k = 250; i 100; i++, k--) { // Здесь располагаются операторы второго цикла. } for (i = 0, j = 0; i 100; i++, j--) { // Здесь располагаются операторы первого цикла. }
А вот ещё один довольно странный оператор цикла, в котором, тем не менее, абсолютно корректно соблюдены принципы областей действия имён, областей видимости имён, а также соглашения о соотнесении имён и областей их действия: for (int x; x 10; x++) {int x = 0; x++;}
Так что не забываем о том, что область действия имён в заголовке цикла шире от области действия имён в теле цикла. И вообще, если можно, избавляемся от объявлений в заголовке оператора цикла.
Оператор цикла do … while называется оператором цикла с постусловием. От циклов с предусловием он отличается тем, что сначала выполняется оператор (возможно, составной), а затем проверяется условие выполнения цикла, представленное выражением, которое располагается в скобках после ключевого слова while. В зависимости от значения этого выражения возобновляется выполнение оператора. Таким образом, всегда, по крайней мере один раз, гарантируется выполнение оператора цикла. int XXX = 0; do {cout XXX endl; XXX++;} while (XXX 0);
Определение и инициализация объекта-представителя класса
Определение объекта предполагает выделение области памяти, достаточное для размещения данных-членов объекта и организацию ссылки на объект.
В C++ существует множество способов определения (создания) объектов.
В частности, объект может быть создан:
как глобальная переменная, как локальная переменная, как элемент области активации при вызове функции, при явном обращении к конструктору, в результате выполнения выражения размещения, как временная переменная.
В каждом из этих случаев в определении объекта принимают участие конструкторы, передача управления которым при создании объекта обеспечивается транслятором, как правило, без участия программиста.
Особенности объявления конструктора в C++ и его свойства делают синтаксически неразличимыми выражения преобразования и обращения к конструктору. В ряде случаев можно утверждать, что передача управления конструктору ("вызов" конструктора) является лишь побочным эффектом выполнения выражения преобразования.
Для изучения свойств конструктора мы объявим новый класс - класс комплексных чисел. Это благодарный пример для изучения объектно-ориентированного программирования. В дальнейшем мы не раз будем обращаться к этому классу.
class ComplexType { public: double real, imag; /* Действительная и мнимая часть комплексного числа. */ }; /*Это было объявление класса, а сейчас - определения объекта.*/ ComplexType GlobalVal; /* Как глобальная переменная.*/ void main () { ComplexType MyVal;/* Как локальная переменная.*/ ComplexType *pVal = new(ComplexType); /* В результате выполнения выражения размещения*/ }
Если объект создаётся в результате выполнения выражения размещения, он располагается в динамической памяти и остаётся безымянным, поскольку значениями выражений размещения является значение указателя на выделенную область памяти. В этом случае обращение к объекту возможно только по указателю, означенному в результате выполнения этого выражения.
В объявлении класса невозможно указать начальные значения данных-членов (это всё-таки объявление). И поэтому после создания объекта эти значения оказываются неопределёнными. Объекты приходится дополнительно инициализировать, специально присваивая значения данным-членам класса.
В принципе нет ничего предосудительного в поэтапном определении и модификации объектов. Для этого достаточно определить несколько управляющих функций-членов класса, которые можно вызывать "от имени новорожденного объекта" для задания соответствующих значений данным-членам класса. Однако в C++ существует возможность совмещения процесса определения и инициализации. Дело в том, что у оператора определения объекта сложная семантика. C++ позволяет совмещать обязательные работы по размещению объекта в памяти, выполняемые специальным программным кодом, который автоматически подставляется транслятором на стадии генерации и работы по инициализации значений данных-членов.
Для программиста это может означать только одно: он может самостоятельно включить собственные операторы в особый список операторов, после чего транслятор гарантирует, что эти операторы будут выполняться в нужное время. Нам остаётся выяснить, куда следует встраивать эти операторы, и когда они будут выполняться.
Тот самый список операторов, который выполняется при определении объекта, и называется конструктором.
Основное назначение конструктора - определение объектов. Если программист не вмешивается в процесс построения объекта, транслятор свмостоятельно формирует стандартный конструктор, который невидим для программиста. Как и когда он используется, и что при этом он делает - об этом известно только транслятору.
Программист может объявить и определить в классе собственные версии конструктора. Собственная версия конструктора - это собственная последовательность операторов. Эти операторы выполняются непосредственно после прогаммного кода, который обеспечивает регламентные работы по созданию объекта.
Существуют строгие правила оформления подобных альтернативных конструкторов, поскольку транслятор должен понимать, что он имеет дело именно с конструктором, а не с какой-либо функцией. Правилам построения и особенностям конструкторов посвящается следующий раздел.
Ошибки и исключительные ситуации
Мы завершаем путь. Всё это время мы стремились не допускать ошибок в выражениях, операторах, объявлениях, определениях, макроопределениях, программах. Но до сих пор у нас нет чёткого представления о том, что такое ошибка.
В общем случае под ошибкой мы будем понимать несоответствие правилу, алгоритму. Это рабочее определение. Конечно, правила бывают нечёткими, алгоритмы - некорректными. Это неважно. В любом случае можно сказать, что "всё не так, как должно быть". И этого достаточно.
Разные ошибки проявляют себя по-разному и могут быть обнаружены в разное время, на разных стадиях жизненного цикла программы и при различных обстоятельствах.
Выявлением некорректных макроопределений, несуществующих заголовочных файлов и неверных условий компиляции занимается препроцессор. Ошибки препроцессора выявляются на ранних этапах трансляции. Сами по себе они не проявляются.
Транслятору C++ хорошо известен синтаксис языка. Поэтому нарушение правил порождения слов, выражений и предложений, в том числе и неуместное использование ключевых слов, достаточно легко обнаруживается на стадии трансляции.
Транслятор распознаёт константные выражения различной сложности. Он способен самостоятельно производить арифметические вычисления. Так что с вопросами определения статических массивов также не возникает никаких проблем.
На этапе трансляции распознаются леводопустимые выражения. Поэтому значительная часть ошибок, связанных с некорректным использованием операций присвоения также выявляется на стадии трансляции.
Многие ошибки несоответствия типов также могут быть выявлены на этапе трансляции, в ходе создания объектного кода. Здесь следует вспомнить об операции явного преобразования типа, которая отключает контроль транслятора за типами.
На этапе создания исполнительного модуля программа (или система) компоновки способна распознать объявленные и неопределённые переменные и функции, а также незавершённые объявления классов.
В ряде случаев на этапе создания объектного кода могут выявляться неопределённые и неиспользуемые переменные, и даже потенциально опасные фрагменты кода. Транслятор может предупредить об использовании в выражениях неинициализированных переменных.
Однако не все расхождения с правилами, идеальным схемами и алгоритмам могут быть обнаружены до того момента, пока программа находится в состоянии разработки.
Существует категория ошибок, которые не способны выявить даже самые изощрённые препроцессоры, трансляторы и программы сборки. К их числу относятся так называемые ошибки времени выполнения. Эти ошибки проявляются в ходе выполнения программы.
Мы не раз подчёркивали, что в C++ часто возникают ситуации, при которых ответственность за правильность выполнения операций, операторов и даже отдельных функций целиком возлагается на программиста. Арифметические вычисления (деление на нуль), преобразования типа, работа с индексами и адресами, корректная формулировка условий в операторах управления, работа с потоками ввода-вывода - это далеко не полный перечень неконтролируемых в C++ ситуаций.
Ошибки времени выполнения, возникающие непосредственно в ходе выполнения программы, в терминах объектно-ориентированного программирования называются исключительными ситуациями. Исключительные ситуации - это события, которые прерывают нормальный ход выполнения программы.
Различают синхронные и асинхронные исключительные ситуации.
Синхронная исключительная ситуация возникает непосредственно в ходе выполнения программы, причём её причина заключается непосредственно в действиях, выполняемых самой программой.
Асинхронные исключительные ситуации непосредственно не связаны с выполнением программы. Их причинами могут служить аппаратно возбуждаемые прерывания (например, сигналы от таймера), сообщения, поступающие от внешних устройств или даже от локальной сети.
Реакция на исключительную ситуацию называется исключением.
Заметим, что исключительная ситуация не всегда неожиданна. Очень часто при разработке алгоритма уже закладывается определённая реакция на вероятную ошибку.
Например, функция, размещающая целочисленные значения в массиве по определённому индексу, может самостоятельно следить за допустимыми значениями индекса. Она возвращает единицу в случае успешного размещения значения и нуль, если значение параметра, определяющего индекс, не позволяет этого сделать.
#define MAX 10 int PushIntArray(int* const, int, int); void main() { int intArray[MAX]; int IndexForArray, ValueForArray; ::::: for (;;) { ::::: // Значения IndexForArray и ValueForArray меняются в цикле. if (!PushIntArray(intArray, IndexForArray, ValueForArray)) { cout " Некорректное значение индекса" endl; IndexForArray = 0; } ::::: } ::::: } int PushIntArray(int* const keyArray, int index, int keyVal) { if (index = 0 index MAX) { keyArray[index] = keyVal;// Спрятали значение и сообщили об успехе. return 1; } else return 0; // Сообщили о неудаче. }
Перед нами самый простой вариант исключения как формы противодействия синхронной исключительной ситуации. Из функции main вызывается функция, PushIntArray, которой в качестве параметров передаются адрес массива, значение индекса и значение, предназначенное для сохранения в массиве.
Функция PushIntArray проверяет значение индекса и возвращает соответствующее сообщение. Эта функция выявляет возможные ошибки и уведомляет о них вызывающую функцию. Подобное сообщение о неудаче можно рассматривать как прообраз генерации (или возбуждения) исключения.
Вызывающая функция может корректировать значение индекса: исправление выявленных ошибок (то есть реакция на исключение) - компетенция вызывающей функции.
Очевидно, что не всегда исключение может быть возбуждено по такой простой схеме. Например, функция, возвращающая результат деления двух действительных чисел, должна предусматривать вероятность возникновения ситуации, при которой делитель оказывается равным нулю. Ожидаемая исключительная ситуация также может сопровождаться определённой реакцией, нейтрализующей возможные последствия вероятной ошибки.
#include iostream.h #define EXDIVERROR 0.0 /* Здесь может быть определено любое значение. Это не меняет сути дела. Так кодируется значение, предупреждающее об ошибке. Не самая хорошая идея: некоторые корректные значения всегда будут восприниматься как уведомления об ошибке. */ float exDiv (float, float); void main() { float val1, val2; ::::: if (exDiv(val1, val2) == EXDIVERROR) { ::::: cout "exDiv error…"; // Здесь можно попытаться исправить ситуацию. ::::: } } float exDiv (float keyVal1, float keyVal2) { if (val2) return keyVal1/keyVal2; return EXDIDERROR; }
Функция exDiv может быть модифицирована следующим образом: возвращаемое целочисленное значение сообщает о ходе вычисления, а непосредственно сам результат вычисления передаётся по ссылке.
Подобная схема противодействия исключительным ситуациям уже применялась при работе со стеком.
#include iostream.h int exDiv (float, float, float); void main() { float val1, val2, resDiv; ::::: if (!exDiv(val1, val2, resDiv)) { ::::: cout "exDiv error…"; ::::: } } int exDiv (float keyVal1, float keyVal2, float keyRes) { if (val2) {keyRes = keyVal1/keyVal2; return 1;} return 0; }
Ещё один возможный вариант обратной связи между вызываемой и вызывающей функциями заключается в определении специального класса, объединяющего в одном объекте возвращаемое значение и служебную информацию о результате выполнения функции. В таком случае возвращаемое значение превращается в исключение лишь в случае возникновения исключительной ситуации.
#include iostream.h class DivAnsver { public: int res; float fValue; // Конструктор. DivAnsver(): res(1), fValue(0.0) {}; // ctorИнициализаторы в действии! }; DivAnsver exDiv (float, float); void main() { DivAnsver Ansver; Ansver = exDiv(0.025, 0.10); cout Ansver.fValue "..." Ansver.res endl; Ansver = exDiv(0.025, 0.0); cout Ansver.fValue "..." Ansver.res endl; } DivAnsver exDiv (float val1, float val2) { DivAnsver Ans; if (val2) Ans.fValue = val1/val2; else Ans.res = 0; return Ans; }
Функция exDiv возвращает значение объекта Ans (предопределённый конструктор копирования об этом позаботится). При этом, если деление возможно, значение данного-члена res оказывается равным единице, а fValue принимает значение частного от деления. В противном случае res устанавливается в нуль и объект Ans становится исключением.
Подобным изменениям можно подвергнуть объявление класса, реализующего стек: возвращаемое функцией pop() значение объекта-представителя шаблонного класса мог бы содержать результат выполнения функции и значение содержимого стека.
И опять в этом случае применима традиционная схема: вызывающая функция обращается к вызываемой функции с предложением выполнить конкретное действие. Последняя принимает решение относительно возможности выполнения этого действия и если возможно, выполняет его. В любом случае вызывающая функция уведомляется о проделанной работе, получая либо результат выполнения, либо исключение.
На основе получаемой информации, вызывающая функция принимает соответствующее решение, которое может состоять в продолжении выполнения программы, попытке исправления ошибочной ситуации, либо аварийной остановке выполнения программы.
Казалось бы, всё хорошо и на этом можно было бы остановиться. Однако, нет пределов совершенству!
Существует целый ряд проблем, связанных с подобным способом организации программного кода. Рассмотрим некоторые из них.
Структура вызывающей функции определяется множеством значений, которые может возвратить вызываемая функция. Каждое возвращаемое значение, как правило, сопровождается определённой реакцией. Чем больше вариантов возвращаемых значений и исключений, тем менее наглядным, понятным и легкочитаемым оказывается программный код вызывающей функции.
С ростом числа вариантов возвращаемых значений становится всё более актуальной проблема разделения "положительных" и "отрицательных" ответов.
И вообще, если вызываемая функция возвращает несколько вариантов исключений, то программный код, необходимый для адекватной реакции на ошибки, может превысить объём кода, реализующего основную логику программы.
Наконец, конструкторы и деструкторы вообще не возвращают никаких значений. Поэтому они не способны сообщить о своих проблемах общепринятым способом. Для них приходится специально изобретать особые нестандартные средства взаимодействия.
Основные свойства массивов
Всё, что здесь обсуждается, имеет, прежде всего, отношение к версии языка Borland C++ 4.5. Однако маловероятно, что в других версиях языка массив обладает принципиально другими свойствами.
Первое специфическое свойство массивов заключается в том, что определение массива предполагает обязательное указание его размеров. Зафиксировать размер массива можно различными способами (о них мы уже говорили), однако это необходимо сделать непосредственно в момент его объявления, в соответствующем операторе объявления.
В модулях многомодульной программы массив определяется в одном из модулей (в главном модуле) программы. В остальных модулях при объявлении этого массива используется спецификатор extern. Подобное объявление может быть включено и в главный модуль. Главное, чтобы транслятор мог различить объявления и собственно определение.
В объявлениях со спецификатором extern можно указывать произвольные размеры объявляемого массива (лишь бы они были описаны в виде константного выражения), а можно их и не указывать вовсе - транслятор всё равно их не читает.
int intArray1[10] = {0,1,2,3,4,5,6,7,8,9}; extern intArray1[]; extern intArray1[1000]; /*Казалось бы, если транслятор всё равно не читает значение константного выражения в объявлении, то почему бы там не записать выражение, содержащее переменные?*/ int ArrVal = 99; extern intArray1[ArrVal + 1]; /*Однако этого сделать нельзя. ArrVal не константное выражение.*/
Но зато он очень строго следит за попытками повторной инициализации.
extern intArray1[10] = {9,9,9,}; /*Здесь будет зафиксирована ошибка. Хотя, если в объявлении не проверяется размерность массива, то какой смысл реагировать на инициализацию…*/
Второе свойство массивов заключается в том, что объекту типа массив невозможно присвоить никакого другого значения, даже если это значение является массивом аналогичного типа и размерности:
char chArray_1[6]; char chArray_2[] = {'q', 'w', 'e', 'r', 't', 'y'}; Попытка использовать оператор присвоения вида chArray_1 = chArray_2;
вызывает сообщение об ошибке, суть которой сводится к уведомлению, что выражение chArray_1 не является леводопустимым выражением.
Следует заметить, что подобным образом ведёт себя и константный указатель, с которым мы познакомились раньше. Он также требует немедленной инициализации (это его единственный шанс получить определённое значение) и не допускает последующего изменения собственного значения.
Часто указатель один "знает" место расположения участка памяти, выделенного операциями или функциями распределения памяти. Изменение значения этого указателя приводит к потере ссылки на расположенный в динамической памяти объект. Это означает, что соответствующая область памяти на всё оставшееся время выполнения программы оказывается недоступной.
По аналогичной причине невозможна и операция присвоения, операндами которой являются имена массивов.
Операторы
intArray1 = intArray2; intArray1[] = intArray2[];
не допускаются транслятором исключительно по той причине, что имя массива аналогично константному указателю. Оно является неизменяемым l-выражением, следовательно, не является леводопустимым выражением и не может располагаться слева от операции присвоения.
Заметим, что при создании в динамической памяти с помощью выражения размещения безымянных массивов объектов (при инициализации указателей на массивы) инициализаторы не допускаются. Инициализатор в выражении размещения может проинициализировать только один объект. И дело здесь не в особых свойствах выражения размещения, а в особенностях языка и самого процесса трансляции.
Рассмотрим процессы, происходящие при выполнении оператора определения массива. Они во многом аналогичны процессам, происходящим при определении константного указателя:
по константному выражению в описателе или на основе информации в инициализаторе определяется размер необходимой области памяти. Здесь сразу уже необходима полная информация о размерности массива. Размер области памяти составляет равняется произведению размера элемента массива на размерность массива, выделяется память, адрес выделенной области памяти присваивается объекту, который по своим характеристикам близок константному указателю (хотя это объект совершенно особого типа).
Теперь можно вспомнить объявление, которое было рассмотрено нами в одном из прошлых разделов. Объявление массива int intArray_7[];
воспринимается транслятором как ошибочное объявление исключительно по причине функционального сходства между объявлением массива и объявлением константного указателя. Массив, как и константный указатель должен быть проинициализирован в момент объявления.
Основные типы C++
Основные типы в C++ подразделяются на две группы: целочисленные типы и типы с плавающей точкой (для краткости их будем называть плавающими типами). Это арифметические типы.
В C++ нет жёсткого стандарта на диапазоны значений арифметических типов (в стандарте языка оговариваются лишь минимально допустимые значения). В принципе, эти диапазоны определяются конкретной реализацией. Обычно выбор этих характеристик диктуется эффективностью использования вычислительных возможностей компьютера. Зависимость языка от реализации создаёт определённые проблемы переносимости. C++ остаётся машинно-зависимым языком.
К целочисленным типам относятся типы, представленные следующими именами основных типов:
char short int long
Имена целочисленных типов могут использоваться в сочетании с парой модификаторов типа:
signed unsigned
Эти модификаторы изменяют формат представления данных, но не влияют на размеры выделяемых областей памяти.
Модификатор типа signed указывает, что переменная может принимать как положительные, так и отрицательные значения. Возможно, что при этом самый левый бит области памяти, выделяемой для хранения значения, используется для представления знака. Если этот бит установлен в 0, то значение переменной считается положительным. Если бит установлен в 1, то значение переменной считается отрицательным.
Модификатор типа unsigned указывает, что переменная принимает неотрицательные значения. При этом самый левый бит области памяти, выделяемой для хранения значения, используется так же, как и все остальные биты области памяти - для представления значения.
В ряде случаев модификаторы типа можно рассматривать как имена основных типов.
Здесь также многое определяется конкретной реализацией. В версиях Borland C++ данные типов, обозначаемых как signed, short и int в памяти занимают одно и то же количество байтов.
Особое место среди множества основных целочисленных типов занимают перечисления, которые обозначаются ключевым словом enum. Перечисления представляют собой упорядоченные наборы целых значений. Они имеют своеобразный синтаксис и достаточно специфическую область использования. Их изучению будет посвящён специальный раздел.
Здесь также многое зависит от реализации. По крайней мере, для Borland C++ 4.5, основные характеристики целочисленных типов выглядят следующим образом:
Тип данных | Байты | Биты | Min | Max |
signed char | /td> | /td> | - 128 | /td> |
unsigned char | /td> | /td> | /td> | /td> |
signed short | /td> | -32768 | ||
enum | /td> | -32768 | ||
unsigned short | /td> | /td> | ||
signed int | /td> | -32768 | ||
unsigned int | /td> | /td> | signed long | /td> | -2147483648 | /td> |
unsigned long | /td> | /td> | /td> |
float double long double
Как и ранее, модификатор типа входит в число имён основных типов.
Плавающие типы используются для работы с вещественными числами, которые представляются в форме записи с десятичной точкой, так и в "научной нотации". Разница между нотациями становится очевидной из простого примера, который демонстрирует запись одного и того же вещественного числа в различных нотациях.
977*10**2 2.977E2
и ещё один пример…
355*10**-3 2.355E-3
В научной нотации слева от символа E записывается мантисса, справа - значение экспоненты, которая всегда равняется показателю степени 10.
Для хранения значений плавающих типов в памяти используется специальный формат представления вещественных чисел. Этот формат называется IEEE форматом.
Ниже представлены основные характеристики типов данных с плавающей точкой (опять же для Borland C++ 4.5):
Тип данных | Байты | Биты | Min | Max |
float | /td> | -38 | +38 | |
double | /td> | -308 | +308 | |
long double | -4932 | +4932 |
Имена типов данных и их сочетания с модификаторами типов используются для представления данных различных размеров в знаковом и беззнаковом представлении:
char signed char unsigned char
short signed short unsigned short
signed unsigned
short int signed short int unsigned short int
int signed int unsigned int
long signed long unsigned long
long int signed long int unsigned long int
Все эти типы образуют множество целочисленных типов. К этому множеству также относятся перечисления.
А вот сочетания имён типов и модификаторов для представления чисел с плавающей точкой:
float double long double
Вот и всё об основных типах. Помимо основных типов в C++ существуют специальные языковые средства, которые позволяют из элементов основных типов создавать новые, так называемые производные типы.