
- •Введение
- •1. Краткая история технологии программирования
- •2. Некоторые аспекты технологии программирования и внедрения по.
- •3. Технологические процессы и жизненный цикл программного обеспечения.
- •4. Блок-схемы и алгоритмы, программы и подпрограммы.
- •Как всё это выглядит на языках программирования.
- •5. Данные.
- •7. К вопросу технологических скачков в программировании.
- •8. Отладка.
- •9. Документация и отладка.
- •Список литературы
- •Содержание
5. Данные.
Любую программу можно рассмотреть, как некий набор операций, который применяется к некоторым данным в некоторой последовательности.
Во время выполнения программы существуют, условно, две группы данных: данные, определяемые программистом и данные, определяемые системой. Данные, определяемые программистом, состоят из элементов, которые программист явно определяет в своей программе и которыми он манипулирует сам – числа, массивы, файлы и т.п. Данные, определяемые системой, формируются средой функционирования программы и зависят, как от языка реализации, так и от операционной системы и техники, на которой функционирует программа. Например, ЭВМ может иметь аппаратную реализацию виртуальных адресов, а может и не иметь, что, конечно, влияет на наличие служебных данных.
Хотя программист и не определяет явно служебных данных, он обязан знать об их наличии и учитывать при программировании. Грамотный программист, нередко, может получать доступ к служебным данным и сам их менять или использовать. В своё время в DOS, большинство грамотных программистов при работе с графикой пользовались непосредственным доступом к видеопамяти, а не стандартными средствами графики, это было на порядок быстрее. В Pascale – возможны массивы с задаваемыми границами, а, следовательно, где-то хранится информация с описанием массива и его границами, чего нет в Cи, где нет многомерных массивов и нет операций с массивами и т.д.
Или: любой Cи – программист, только начав работать в Cи со строками, быстро разбирался, что в Cи нет операций с массивами, а следовательно и со строками, и вообще в Cи - строка это простая последовательность байт ограниченная байтом 0x00. Обратите внимание на то, что программиста вынуждают знать внутреннее представление строк! Спросите любого Basic – программиста: “Как представляется строка в Basice?» В девяти случаев из десяти вы не получите правильного ответа. Важно понимать, что язык может способствовать ознакомлению программиста с внутренним представлением (Cи++) и наоборот всячески скрывать наличие служебных данных, препятствуя проникновению программиста «во внутрь» - Basic.
При применении языков, обычно, у программиста две альтернативы: использовать язык с явным описанием данных или вовсе не описывать данные. Не следует думать, что отсутствие в языке средств описания данных – это плохо. Во многих очень серьёзных языках данные можно либо вовсе не описывать, либо не описывать частично, например, такой язык, как АПЛ не имеет средств явного описаний массивов, но великолепно динамически работает с массивами.
Явное описание данных или декларация служит трём главным целям.
1. Более эффективное хранение структур данных и доступ к ним. Наиболее очевидная цель декларации состоит в том, чтобы указать свойства структур и данных, остающиеся неизменными во время выполнения программы. Транслятор оптимальным образом размещает данные в памяти и генерирует соответствующие команды работы с данными.
2. Улучшение управления памятью. Именно декларация позволяет определить области видимости данных и время их существования. Декларация позволяет применять к управлению данными более простые алгоритмы. Если массив объявлен и имеет статическое распределение в памяти, то достаточно знать тип данных и границы массива. Если массив создаётся динамически и размеры его могут быть динамически изменены в ходе работы программы, то необходимо где-то хранить информацию о точках создания и уничтожения или перераспределения массива, кроме того, чаще всего добавляется информация от операционной среды о блоках памяти и т.п.
3. Статическая поверка типа. В языке программирования операция называется специфической, если тип её операндов и результата неизменен. Операция называется универсальной, если тип её операндов и результата может меняться. Ясно, что специфические операции выполняются значительно быстрее, так как не требуют слежения за типом данных. Структура команд машины, вообще построена так, что в ней отсутствуют универсальные операции. В свою очередь, программист стремится, как можно меньше контролировать типы данных и, просто необходимо, ему позволить использовать универсальные операции.
Говорят, что язык допускает статическую проверку типов, если определение языка требует наличия в программе описаний типа, позволяющих транслятору выполнить во время трансляции проверку типа и транслировать универсальные операции утверждений программы в специфические операции машины. Альтернативой является динамическая проверка типа, осуществляемая во время выполнения программы.
Реальные языки программирования имеют полный набор способов деклараций, от обязательного в языке Cи и Cи++ до их отсутствия в Basic. Многие языки позволяют выполнять неявные декларации: Fortran – все необъявленные переменные с первой буквой I … N – целые, остальные плавающие.
Описание любых данных состоит из двух частей: описание структуры памяти и описание способа интерпретации этой структуры. Определяется длина интерпретируемых данных, описывается какой бит за что отвечает, какие биты старшие, а какие младшие, последовательность их расположения, возможные технические требования к адресации и т.п. Затем описывается способ интерпретации описываемой совокупности бит (байт). Конкретные четыре байта могут быть рассмотрены как целое, как плавающее или как четыре знаковых символа.
Таким образом, сначала даётся собственно описание данных, а затем операций, которые можно совершать с описываемыми данными. Схема общая. Если вы возьмётесь описывать любой объект, то всегда сначала описываете, что этот объект представляет, а затем - что можно с этим объектом делать: какие операции может выполнять сам объект и какие операции можно выполнять над объектом.
Аппаратное описание данных, теперь как правило, рассматривается в курсе архитектуры ЭВМ. Современные средства программирования достаточно далеко «отодвинули» среднего программиста от аппаратуры. Здесь мы не будем касаться аппаратных представлений, наша цель выполнить обзор существующих структур данных.
Итак, данные. Простые переменные. Идентификатор, который в течение всего времени выполнения программы связан с одним простым элементом данных, называется простой переменной. Одним из простых элементов данных являются числа. Как они представляются в памяти машины? Обычной практикой является прямое использование аппаратного представления.
Целые числа. Таким образом, мы всегда имели представление целых чисел в виде чисел с фиксированной запятой. В зависимости от аппаратуры, длина целого числа могла быть либо два, либо четыре байта. Величина числа ограничивалась количеством предоставляемых под представление целого числа байт.
Дробные числа, обычно, представляются плавающими. Обычно аппаратура позволяла работать с простым плавающим числом или с двойным. Не следует забывать, что ещё 286 ПЭВМ не имели встроенных средств для работы с плавающими числами.
Мы достаточно обсуждали особенности работы с плавающими и целыми. Важно, что вышеуказанное представление, позволяет сразу обратиться к числу как к единому целому. Одна операция доступа и мы получаем целиком число. Дескрипторы, обычно, отсутствуют.
Комплексные числа, чаще всего не имеют аппаратного представления. Их очень удобно представлять программно в виде пар плавающих чисел, например в виде структуры. Код достаточно простой, чтобы его здесь приводить. Сейчас, делается немного по-другому, а именно, строится класс «комплексные числа», но это обсудим позже.
Несколько сложнее обстоит дело с рациональными числами, если мы хотим представить их непосредственно в виде простых дробей. В процессе вычислений знаменатели имеют тенденцию неограниченно возрастать, что приводит к тому, что представлению рациональных чисел в виде пары целых не хватает длины аппаратного представления. Таким образом, для простого (с точки зрения математики) представления рациональных чисел требуется значительный объём программного моделирования. Отметим, что программное моделирование требует, прежде всего, моделирования всех операций.
Вообще любое представление данных тесно связано с операциями, которые мы собираемся выполнять с данными. Это достаточно иллюстрируется предыдущими представлениями чисел. Можно числа опять-таки представить и в виде цепочки литер, но если для печати такое представление оптимально, то для математических операций совершенно не подходит, хотя принципиально возможно написать программу, манипулирующую именно такими числами.
Как представляется не числовая информация? Поскольку минимально адресуемой частью памяти является байт, то каждому состоянию байта соответствует один символ. То есть мы имеем таблицу из 256 значений, где на соответствующих местах находятся определённые алфавитные символы. На самом деле, этого числа значений достаточно только для алфавитного письма. Уже слоговые азбуки не все можно реализовать в этом количестве значений, идеографические системы совершенно не помещаются в эти границы. Поскольку таблицу соответствия составляет человек, то ясно, что конкретных кодировок составлено множество. В совремённом мире используется около 25 систем письма, а различных кодировок этих письменностей известно более 170.
В младшей части кодовой таблицы (коды от 0x00 до 0x79) расположена таблица ASCII – American Standart Code for Information Exchange. В этой таблице находятся латинские буквы, цифры, знаки препинания и общепринятые управляющие символы. Все национальные кодировки расположены во второй половине таблицы, от кода 0x80 до кода 0xFF. Нас, прежде всего, интересуют таблицы кодировки кириллицы.
В 74 году вышел ГОСТ-19768-74, который определил первую восьмибитную кодировку кириллицы. Код, задаваемый этим гостом известен как таблица КОИ-8. Эта таблица, относительно кириллицы, имеет массу недостатков: нарушен алфавитный порядок кириллических символов, что создаёт трудности при сортировке строк и их сравнении. Несмотря на это именно КОИ-8 стала стандартной кодировкой русифицированных версий системы Unix. Она же была предложена в качестве основы для кодировки ISO-IR-111, предложенной в качестве общеевропейского стандарта. Кроме того, в 1993г. в таблицу добавили букву ё и символы псевдографики и стали использовать сначала в сети РЕЛКОМ, а затем в Internete. То есть КОИ-8 стала стандартом для русскоязычной электронной почты.
Параллельно, с приходом в Россию компьютеров клона IBM и операционной системы MS-DOS появилась таблица кодировки, которая называется альтернативной. Она используется всеми программами, работающими под управлением DOS. Ясно, что приход системы Windows принёс с собой новую таблицу кодировки: Windows-1251.
В результате всего сложилась следующая ситуация: Большинство Web-серверов и почтовых серверов работают под управлением Unix-систем, поэтому для них стандартной кодировкой является код КОИ-8Р. Большинство клиентских машин работает под управлением Windows и для них стандарт – Windows-1251. Альтернативная кодировка используется при эмуляции DOS системой Windows. Кроме того, есть ещё и Macintosh, где, конечно же, другая таблица перекодировки.
Ситуация сложилась совсем ненормальная. Поэтому лет десять назад пришла идея создать единую таблицу для всех национальных алфавитов. Такой подход был предложен стандартом Unicode, или ISO/IEC 10646. Кажется, что это нереально. Однако из примерно 6700 живых языков официальными являются только около 50, которые пользуются примерно 25 системами письменности. Эти цифры вполне приемлемы. Для кодировки Unicode необходимы уже двухбайтовые коды символов. Unicode обладает целым рядом положительных качеств. Прежде всего, это не просто большая таблица кодировки, а база данных. Здесь каждому коду сопоставлен определённый символ, имеющий набор свойств и изображающую его графему. Такая база позволяет преобразовывать строчные буквы в прописные и обратно, сортировать строки и многое другое. При этом блок кириллицы в Unicode включает все живые кириллические алфавиты: русский, украинский, белорусский, болгарский и т.п., а также все буквы церковно-славянской письменности.
Полная поддержка Unicode позволит правильно работать с текстовой информацией на любом количестве языков. Базы данных смогут хранить и правильно сортировать и выводить информацию на любом языке. Для этого необходимо:
1. Операционные системы должны поддерживать Unicode на уровне ввода, хранения и отображения текстовых строк.
2. Необходимы «умные» драйвера клавиатуры, позволяющие нам вводить символы любого блока Unicode и передающие их коды операционной системе.
3. Прикладные программы должны поддерживать отображение всех символов Unicode и выполнять над ними общепринятый набор символьных операций.
4. Должна быть поддержка всех устаревших кодировок, так как они будут ещё некоторое время существовать параллельно с Unicode.
Последовательного решения всех этих задач нет пока ни в одной операционной среде. -
Тем не менее, следует знать, что в Cи++ предусмотрен тип wchar_t – для представления строк Unicode. Опять-таки официально заявлено, что этот тип сохраняет все свойства short int. Скорее всего, он представлен последовательностью целых полуслов.
Рассмотрим попутно и такую простую задачу, как перекодировка. Принципиально её должен понимать каждый грамотный программист. Задача ставится так: в последовательности символов заменить одни символы на заданные другие. Ясно, что у нас должна быть таблица соответствия одних символов другим. Тогда просмотрев первую таблицу и найдя там перекодируемый символ, мы по тому же индексу выбираем символ из другой таблицы и подставляем вместо исходного.
Реально используется несколько другой алгоритм. Входной символ рассматривается как индекс, по которому из таблицы выбирается замещающий его символ. Во-первых, такой алгоритм требует всего одну таблицу вместо двух. Во-вторых, скорость его работы не зависит от перекодируемого текста, так как нет последовательного просмотра таблицы. Тогда программа перекодировки может выглядеть следующим образом.
Пусть void Transl (char * A, int La, char * Tbl, char * B); обращение к программе перекодировки, где: A – входная строка; B – выходная строка; La – длина входной строки; Tbl – таблица кодировки. Согласно стандарту языка Cи программа будет такая:
for (int i = 0; i < La; i++)
*(B + i) = * (Tbl + (*(A + i))); // здесь используется, что char в “Cи”
// рассматривается как int !
Реально такой код поддерживается не всеми трансляторами. Watcom Cи, например, справился с таким кодом и реально сгенерировал правильную программу, но Borland Cи не стал этого делать. Под Borland и, конечно Cи++Builder программу надо немного подкорректировать, оставив идею прежней:
union IntChar {
int j;
char C[4];
} Indx;
int i;
Indx.j = 0;
for (i = 0; i < La; i++) {
Indx.C[0] = *(A+i);
*(B+i) = *(Tbl + Indx.j);
}
Перекодировки используются не только при «оформлении» документа, но и в системах секретности.
Представление строк уже требует определённой структуризации элементов данных. Обычно применяют, по крайней мере, три схемы:
1. Цепочки фиксированной длины, задаваемой в декларации. Допускаются операции, не изменяющие длины цепочки. Элементы цепочки, символы/литеры можно сравнивать, заменять. Изменение длины цепочки сопровождается либо усечением цепочки при увеличении её длины, либо добавлением пробелов справа. Такой способ используется, например в Cobole.
2. Цепочки переменной длины, предел которой задаётся в декларации. В декларации указывается максимальная длина цепочки. Цепочка обрезается по длине, если её длина превышает заданный предел. Этот метод используется в PL/1.
3. Цепочки неограниченной длины. Длина цепочки может быть произвольной. Декларации о предельной длине не требуется. Память для хранения цепочки выделяется по мере необходимости. (Снобол 4).
С представлением строк в Cи++ мы уже знакомы. Фактически в Cи нет типа данных string, как например, в Pascale. Здесь в Cи мы имеем дело с обычным массивом знаков.
В Pascale мы имеем более широкий выбор:
String – как строка длиной не более 255 символов, первый байт которой содержит описатель длины.
AnsiString – строка неограниченной длины. Этот тип сохранён и в Cи++Builder и его можно использовать при программировании на Cи++. Важно отметить, что для этого типа определены все обычные операции для строк, чего, фактически, нет в Cи для обычных строк. Реально AnsiString представляет собой довольно не простую структуру, где и хранятся все сведения о цепочке. При работе с AnsiString вы получаете доступ как ко всей строке целиком, так и к каждому элементу строки в отдельности. Поскольку вся работа с AnsiStirng реализована на Pascale, то начальный индекс элементов строки, как массива будет не 0, а 1. Работа с AnsiString сильно упрощает жизнь, так как для этого класса реализованы все необходимые операции и функции, но следует помнить, что этот тип есть только в Cи++Builder (Delphi – тоже). В Cи++ AnsiString определён, как class в заголовке dstring.h.
AnsiString преобразуется в обыкновенную строку функцией c_str():
AnsiString St; char Bu[128]; Bu = St.c_str();
Функцией WideChar AnsiString преобразуется в тип wchar_t:
AnsiString St; wchar_t Wbu[128];
wchar_t * U; U = St.WideChar(Wbu, 128);
Не ясно, почему, но возвращаемый указатель должен отличаться от указателя аргумента. Возможно и обычное применение: St.WideChar(Wbu, 128);
WideString – также строка неограниченной длины, но символы двухбайтовые, позволяющие представлять строки Unicode. Этот тип также сохранён в Cи++Builder и с ним также можно работать, как с AnsiStirng. Определён тип WideString как class в заголовке wstring.h. Принципиально вы можете выполнить присвоение AnsiString -> WideString. Всё отработает правильно. Однако обратное присвоение в общем случае может привести к неясным ошибкам. Обычно это бывает, тогда когда WideString содержит знаки wchar_t. Поэтому пользоваться WideString следует осторожно, только тогда, когда это действительно необходимо.
Попутно обратим внимание на присвоение данных строкам типа wchar_t. Нельзя написать обычное присвоение, как это делалось со строками char – теперь типы данных разные. Такое присвоение выполняется, если перед строкой поставить букву L – что означает длинные знаки. Однако типу WideString можно присваивать данные достаточно свободно.
Наконец, у нас есть Basic. В нём тоже есть строки и их представление совершенно отлично как от Pascal, так и от Cи. При межъязыковом программировании вопрос представления строк становится очень важным. Фактически, сегодня идёт революция в программировании, а именно стремительное нашествие Active X. Поскольку основные концепции в Active X, были предложены фирмой MicroSoft, то следует ожидать широкого использования Basic, что и имеет место. Таким образом, появляется ещё один тип представления строк – BSTR. Первоначально этот тип был сконструирован специально для совместимости со строками Basic, но впоследствии он стал широко использоваться в COM – технологиях и теперь о Basic напоминает только первая буква в имени типа. BSTR легко перевести в WideString и обратно, но при этом часто возникают определённые проблемы, которые легче обсудить в рамках темы COM – технологии.
Это мы обсудили, я бы сказал – простые естественные типы данных. Данные этих типов широко используются в повседневной жизни. Однако, для программиста, работающего на Assemblere (или в коде) есть ещё один тип «естественных данных» - адрес! При работе на машинно-зависимом уровне программист вынужден освоить способы адресации, используемые на машине, для которой он пишет программу, и способы представления адреса. При работе на алгоритмическом языке программисту не требуется знание способов адресации, так как работу с адресами выполняет за него транслятор. Тем не менее, всем хотелось при производительности труда, достигаемой на алгоритмических языках, получить эффективность и лаконичность Assemblera. (это одна из причин разработки языка Си).
Уже в языке PL\1 появились указатели: переменные этого типа могли получать значение адреса любой другой переменной, с ними можно было выполнять простые арифметические операции. То есть, через указатели в языке PL\1 реализовывалась обыкновенная адресная арифметика, а тип указатель в PL\1 был синонимом типа адрес. Указатель мог быть настроен на адрес переменной любого типа, то есть, сам указатель не имел типа. Автор до сих пор гадает, почему разработчики PL\1 спрятали тип «адрес» за псевдонимом «указатель» и почему тип «адрес» так и не был реализован ни в одном алгоритмическом языке. Отметим также, что в любом руководстве по PL\1 указывалось, что указатели это средство, как сейчас бы заявили, для продвинутого программиста и что использовать указатели надо весьма осторожно, так как они могут быть источником плохо диагностируемых ошибок.
Разработчики языка Си ввели указатели в свой язык. Указатель это переменная, которая может приобрести значение адреса переменной другого, заданного при объявлении указателя типа. То есть, указатель имеет тип, а значит, либо это указатель на целое, либо на плавающее, либо на двойное плавающее число и т.п. (адрес типа не имеет). Смена типа указателя невозможна – указатель на целое не может стать указателем на плавающее. Операции с указателем выполняются так же, как с адресами переменных одного с указателем типа. Другими словами, прибавляя к указателю на целое единицу, вы наращиваете значение указателя на четыре, если длинна целого на машине четыре байта, а, прибавляя единицу к указателю на двойное плавающее, вы наращиваете значение указателя на восемь. Всё это упрощает разработку транслятора, но в общем, как это ни странно, усложняет использование указателей в работе. Программисту постоянно приходится следить за типизацией указателя и либо самому выполнять приведение типов, либо писать специальные средства для выполнения приведения типов указателей.
К тому же структура и синтаксис языка Си таковы, что принуждают программиста к использованию указателей с самого начала (более подробно см. ниже). Но начинающий программист весьма смутно представляет, что такое адрес, а уж что такое указатель понять ещё тяжелее. Учтите при этом, что “*” используется в Си при указателях в двух смыслах: объявление указателя и операция разыменования. Таким образом, указатели в Си стали источником бесконечного числа ошибок. Дошло до жаркой дискуссии о необходимости указателей в языке.
Мы не будем вдаваться в эту дискуссию, поскольку наши цели выполнить обзор типов данных, но заметим, что не само средство, выжившее в языке после стольких модификаций, виновато в появлении ошибок, а программисты, безграмотно использующие эти средства. Следует ещё заметить, что синтаксис Си таков, что начинающие в Си программисты не всегда даже понимают, что они уже используют указатели.
Требование обязательного описания данных достаточно жёстко и нередко препятствует выполнению некоторых «программистских трюков». Впрочем, это теперь так называют некоторые приёмы, ранее широко используемые грамотными программистами. Это в некотором смысле «противозаконные» способы обработки информации. С точки зрения программиста на Assemblere – это обычная практика. Другой разговор, что это противоречит, некоторым парадигмам, декларируемым авторами алгоритмических языков, правда, сами они, не задумываясь, используют эти «незаконные» приёмы.
Одним из способов законного обхода парадигмы обязательного описания данных будет возможность рассмотрения одной области памяти как области одновременного нахождения нескольких переменных разных типов. Приём очень старый, используемый уже в языке FORTRAN: COMMON области. В Си это называется объединение – union. Объявление объединения начинается ключевым словом union, за которым может следовать необязательное описание типа, затем открывающая фигурная скобка, перечисление переменных, закрывающая фигурная скобка и необязательные имена переменных:
union < имя типа > {
int A;
float B;
char c[4];
} Un;
Хитрость в том, что адрес начала всех трёх переменных (A, B, c) один и тот же, то есть все три переменных занимают одну область памяти. Тогда для переменной Un.A генерируются транслятором команды работы с int, а для переменной Un.B транслятор генерирует команды работы с float. Рассматривая Un.c[i], мы работаем с i-ым байтом целого или плавающего, но рассматриваем его как символ. Таким образом, мы с одной стороны указали транслятору, когда какие команды генерировать, с другой стороны мы, фактически имеем тип данного, отсутствующий в языке.
Дело, конечно, не в том, чтобы иметь некий странный тип данных, а в том, что существует множество задач, где жёсткое предварительное задание типа данных существенно ограничивают возможности программиста. Например: вам необходимо просто скопировать одну область памяти в другую. Из условия задачи ясно, что тип данных находящихся в памяти совершенно безразличен как программисту, так и для алгоритма реализации. Однако в Си вы будете вынуждены задать тип данных на обе области памяти, более того это должен быть один тип (чаще всего для описания областей памяти используется тип char). В большинстве подобных случаев требование обязательного описания данных приводит к неоправданному увеличение трудоёмкости реализуемой задачи: вам требуется простенькая функция получения абсолютного значения числа – в Си их аж две: abs для целого и fabs для плавающих, это ещё хорошо, что fabs работает как с float, так и с double.
В Cи++ есть немало способов обхода «парадигмы обязательного описания данных», мы обязательно обсудим часть из них ниже, но дело в том, что нередко требуется средство работы с данными, тип которых определяется динамически. Частично эту проблему в Cи++ снимает тип Variant. Реализован этот тип через обычный union (существует и class Variant – не путать!). В объявлении union Variant просто перечислены наиболее часто встречающиеся типы данных и некоторые, более экзотические типы. Для Variant определены все основные операции. Есть возможность объявить массив типа Variant, но в целом работа с этим типом чрезвычайно неудобна, к тому же требуется знание некоторых макроопределений, которые почти не документированы. Однако, уже попадая в такую область, как работа с базами данных, вы сталкиваетесь данными, тип которых определяется динамически, а, следовательно, и с типом Variant.
Обратим внимание, что уже простое обсуждение представления строк привело нас к незаметной работе с агрегатами данных. Простая строка в Cи – уже массив знаков.
Массивом называется набор элементов одинакового типа. Для работы с любым массивом нам нужно знать его начальный адрес, длину элемента и количество элементов. Tогда адрес i – го элемента получается просто: A + i * l: где l – длина элемента в байтах; i – индекс элемента (при условии, что начальный индекс 0), А – адрес первого элемента. Это самая простая схема. Именно она положена в основу работы с массивами в Cи. Отметим, что при таком представлении, массив занимает непрерывную область памяти машины! Тогда способ адресации памяти в машине накладывает ограничения на максимальную величину массива. Вспомним ограничение в 64K на первых персоналках или в DOSe. Основная проблема при работе с таким агрегатом: не уйти за границы массива. Транслятор не может проверить корректность вашего обращения, поскольку конкретный индекс вычисляется только при работе программы. Следующий момент – инициализация массива. Программист не всегда знает максимальное число элементов в массиве. Тогда он размер массива определяет так, чтобы с гарантией иметь массив, вмещающий все предполагаемые элементы. Значит, чаще всего, часть массива остаётся пустой и, следовательно, программист обязан сам контролировать обращения к массиву, чтобы не обратиться к несуществующему элементу, точнее к непроинициализированному.
В большинстве языков нет операций с массивами. То есть, вы не можете выполнить присвоение A = B, если A и B массивы. Точно также, обычно, вы не можете присвоить массиву скаляр. Здесь язык Cи не исключение. Своеобразие языка Cи в том, что имя массива может использоваться в качестве указателя, что приводит к двусмысленному синтаксису. Если вы имеете следующее описание функции:
void Transp (double * A); то по нему невозможно сказать, что реально должно быть передано в функцию: скаляр или массив! Внутри же функции вы можете обращаться к параметру и как элементу массива и как к простой переменной. Почему это появилось? Да очень просто, как не хотелось создателям языка Си сохранить парадигму передачи параметров в подпрограммы по значению, но они прекрасно понимали, что массив передать по значению весьма накладно. Для передачи массива по значению (так как это организовано на ПЭВМ) необходимо было иметь запас памяти по объёму не менее двойного объёма исходного массива: надо было скопировать массив в стек, а затем из стека скопировать значения в массив в подпрограмме. Вот и получается, что скалярные переменные передаются по значению, а массивы – по ссылке. Впрочем, это не единственная нелогичность языка Си.
Принципиально, работа с одномерным массивом, обычно, не вызывает трудностей. Динамически распределяемые массивы требуют большего внимания, поскольку память под массив необходимо не только запросить, но и освободить. Для запроса и освобождения памяти в Cи++ имеются две операции, отсутствующие в стандартном Cи.
Запросить память можно оператором new, а освободить оператором delete,
new тип <[ количество ]>; Оператор возвращает указатель на выделенную память. Тип указателя обязан совпадать с типом выделяемой памяти. Если по каким – либо причинам память выделена не была, оператор new вернёт NULL. Последнее поддерживается не всеми трансляторами. Новейшие трансляторы формируют исключение bad_alloc и предлагают программисту проверить это исключение в блоке catch, что достаточно неудобно. Кроме того, существуют старые программы, которые «не знают», что такое блок catch. Поэтому, если программисту не хочется возиться с исключениями, что чаще всего и бывает, то он поступает следующим образом:
например: int * A; A = NULL;
A = new int;
if (A == NULL) действия по ошибке выделения памяти.
Пример запроса памяти под массив:
double * B;
int M = 10;
B = new int [M] ;
ещё раз подчёркиваем, к переменной B можно обращаться как к элементу массива - B[i].
Если вы получили память по оператору new, то при завершении работы полученную память необходимо освободить оператором delete. Ни система, ни транслятор не могут проконтролировать- следует ли освобождать память при завершении программы – это целиком возложено на программиста.
delete < [ ] > имя;
например: delete A; или освобождение памяти, выделенной под массив:
delete [] B;
Вообще говоря, в Cи имеются только одномерные массивы. Но правилами языка не оговаривается, что может быть элементом массива. Единственное требование, чтобы это были одинаковые элементы. Тогда возможны массивы массивов или двумерные массивы. Однако, если синтаксис объявления двумерного массива прост, то динамический запрос памяти под двумерный массив и передача его в функцию вызывает затруднения. Приведём программу, динамически запрашивающую память под двумерный массив:
double * * matr;
int i;
int k;
………………………………………………………………………………….
matr = new double * [k];
for (i = 0; i < k; i++) matr[i] = new double [k];
…………………………………………………………….
for (i = 0; i < k; i++) delete [] matr[i];
delete [] matr;
Обратим внимание на то, как объявлен массив matr: указатель на указатель типа double. Первым шагом мы распределяем массив указателей (строку), а затем только получаем память под собственно переменные типа double. Таким образом, памяти нам понадобилось на k элементов больше, поскольку k элементов заняты указателями.
Кроме того, память запрашивалась строками и значит никто не гарантирует непрерывного блока памяти под весь массив! Строка от строки может находиться далече, хотя чаще всего, память выделяется одним блоком. Наконец, обращение к i – тому элементу теперь идёт за два обращение к памяти, а следовательно дольше. Надо отметить, что имеются и небольшие плюсы. Немного изменим выделение памяти в вышеприведённой программе:
matr = new double * [k];
for (i = 0; i < k; i++) matr[i] = new double [m[i]];
где int m[k];
из приведённого текста видно, что можно распределять строки разной длины, сохраняя длину каждой строки в массиве m. Если вопрос экономии памяти стоит очень остро, то хоть здесь мы имеем небольшое преимущество.
Аналогичным образом можно иметь трёхмерный массив и т.д. Ясно, что накладные расходы растут. Для двумерной матрицы 10x10 у нас 10 лишних элементов, что составляет 10%, хотя для такой же матрицы 1000x1000 это всего лишь 0,1%. Для аналогичных трёхмерных матриц эти цифры в процентах останутся примерно такими же. Расходы падают с увеличением размера массива, но вся проблема в том, что у нас, как правило, нет очень больших массивов. Однако основной рост не в размере памяти, а увеличении времени обращения к элементу массива.
Конечно, следует помнить, как распределяется массив: в Cи самый правый индекс растёт быстрее всего – то есть массив распределён по строкам.
В Pascale при объявлении задаются границы изменения индекса массива:
например:
Var
MyAr : array [24..40] of Integer;
или двумерный массив:
MyArr : array [-1..23, 100..200] of Integer;
Существуют две функции: Low (array) и High(array), которые позволяют получать соответственно нижнюю и верхнюю границы массива. Массивы также можно распределить динамически (Delfi), для чего при объявлении массива ни задаются границы измерения.
DinAr : array of Integer; или DinArr : array of array of Integer; Задание конкретных размеров массива выполняется специальной функцией SetLength. Можно перераспределить массив функцией Copy. Обратим ваше внимание на то, что нет даже речи об удалении массива.
Таким образом, где-то хранятся как границы измерения, так и адрес самого массива. Освобождение памяти от распределяемых массивов по- видимому выполняется перед завершением работы всей программы.
Следующий часто применяемый агрегат данных – структура.
Структура – объединение данных разных типов. В Си структура начинается ключевым словом struct, затем идёт необязательное имя типа, фигурная скобка, собственно начинающая структуру, закрывается структура также фигурной скобкой, за которой может следовать необязательное имя переменной. Внутри скобок перечисляются члены структуры: данные простых типов, либо массивы, либо также структуры. Структура в Си используется в двух смыслах: если задано «имя типа», то везде далее вы можете ссылаться на это имя как на некий абстрактный тип данных, создавая объекты этого типа. Если «имя типа» не задано, но сразу заданы имена переменных (за закрывающей скобкой структуры), то распределяются объекты типа заданной структуры, но нет возможности ссылаться на структуру, как на абстрактный тип, так как нет имени типа. Задавая сразу «имя типа» и имена переменных, мы совмещаем эти два случая. Обычно программисты используют структуры именно для описания абстрактных типов данных. Данные, перечисляемые в структуре располагаются в памяти в том порядке, как они объявлены в структуре. К любому элементу структуры можно обратиться, указав сначала имя переменной типа структуры, а затем через точку имя элемента. Структура передаётся в подпрограммы по значению (ещё одна нелогичность языка Си, структура всё-таки агрегат и может быть произвольно большим!). Если вы объявляете структуру как тип, то не можете инициализировать внутренние элементы структуры, так как в этом случае, структура используется только как описание типа для транслятора. Если два объекта имеют тип одной структуры, то для них в Си определена операция присвоения (=).
В Pascale структуры называются запись – record. использование записей в Pascale примерно такое же, как и в Си, с поправкой на синтаксис.
Структуры появились, когда потребовалось автоматизировать работу с файлами заданной структуры. Запись такого файла очень удобно представлять в виде структуры. Однако, правилами языка Си не запрещено в структуру помещать любой тип переменных. Тогда помещая в структуре объявление функции, вы получаете весьма своеобразный объект. Вы получаете набор некоторых данных, объединённых с набором некоторых операций (если рассматривать функцию как операцию). Более того, функции получаются «приписанными» к конкретным объектам: вы не можете обратиться к функции объекта, не указав перед обращением его имя через точку. Таким образом, мы получили набор разных объектов одного типа, которые ещё и ведут себя вполне индивидуально (!), так каждый из этих объектов может ссылаться на свою «личную» функцию, определяющую то или другое поведение объекта. Другими словами – с помощью структур можно моделировать объекты. Это в своё время и заметил Бьёрн Страуструп. Почему он в Cи++ ввёл новый термин class, предоставив, тем не менее, структурам почти те же возможности, лучше выяснить из его же книги [8]. Само понятие класса и объекта мы обсудим несколько ниже в главе посвящённой ООП – объектно-ориентированному программированию.
Этими типами и агрегатами данных обычно исчерпываются встроенные представления данных. Ниже будет рассмотрена ещё одна парадигма, а именно ООП. Само понятие class, хотя и достаточно просто, но несёт в себе гораздо больше, чем просто новый способ представления данных совместно с операциями над ними. Последствия внедрение в программирование этого понятия настолько своеобразны, что имеет смысл рассмотреть всё это в отдельном разделе курса. Однако нами не рассмотрены ещё несколько часто применяемых агрегатов данных.
Мы очень кратко рассмотрим их здесь. Для более подробного ознакомления с нижеперечисляемыми агрегатами данных желающие могут обратиться к соответствующей литературе. Эти конструкции не оказали существенного влияния на технологию программирования, но каждый грамотный программист обязан знать об их наличии, более того, любой грамотный программист легко может сам реализовать эти конструкции.
Стек (stack): структура данных с одним входом в который можно последовательно помещать произвольные объекты. Объекты можно извлекать через вход в порядке обратным поступлению. Из определения видно, что стек осуществляет стратегию: первым пришёл – последним ушёл. Примером стека может быть обыкновенный железнодорожный тупик, в который можно последовательно загонять вагоны. Понятно, что вагоны можно выгнать из тупика только через вход в порядке обратном поступлению.
В случае программной реализации стека он представляет собой некоторую область памяти. В самом простом случае для работы со стеком необходимы только две операции: поместить в стек – push (объект) и получить из стека – pop(). Но так просто стек моделируется только тогда, когда работает с ограниченным кругом объектов, например, вы помещаете в стек только натуральные числа. Тогда возврат операцией pop() нуля может рассматриваться, как признак пустоты стека. То есть, в более сложных случаях необходима возможность получать два свойства стека: «стек пуст» (empty) и «стек заполнен» (обычно, пишут «переполнен»). Нередко, можно получить размер стека (size).
Если помещаемые в стек объекты одного типа, то при моделировании достаточно массива одного с помещаемыми объектами типа. Если же в стек помещаются объекты разных типов, то где-то необходимо вести таблицы что, в каком порядке и какого размера находится в стеке.
Очередь (queue): структура данных с одним входом и одним выходом. Объекты могут последовательно помещаться в очередь через вход и извлекаться из очереди в том же порядке через выход. Очередь осуществляет стратегию: первым пришёл – первым ушёл. В обыденной жизни мы постоянно сталкиваемся с очередями, очередь на получение услуги, например. Максимально приближаясь к жизни, можно определить очередь с приоритетами: объект помещается в очередь согласно определённым условиям и извлекается из очереди в том же порядке.
Таким образом, у очереди также две операции: push (объект) - поместить объект в очередь и pop() – извлечь объект из очереди. Но с очередями чаще всего эти операции имеют другие имена: push_back(объект) – подчёркивается, что объект помещается в конец очереди; pop_front () – объект извлекается из начала очереди.
Очевидно, что очереди также имеют свойства «очередь пуста», «очередь заполнена», «размер очереди». Кроме того, весьма важно знать число объектов находящихся в данный момент в очереди.
Дека, двусторонняя очередь (deque): структура данных с двумя входами, в любой из которых можно последовательно помещать объекты. Извлекать объекты можно через эти же входы. Дека, фактически, совмещает свойства стека и очереди. Операции: push_front(объект), push_back(объект), pop_front(), pop_back(). Те же свойства: «дека пуста», «дека заполнена», «размер деки», «число реальных объектов в деке».
Список (list): структура данных имеющая начало, каждый элемент которой имеет ссылку на следующий. Помещение объектов возможно как в конец, так и в середину. Такой список называется прямым. Если список имеет прямой доступ к концу, вместо начала, а хранимые при объектах ссылки указывают на предыдущие члены списка, то список называется обратным. Наконец, возможна модификация списка с доступом, как к концу, так и к началу и двусторонними ссылками. Операции: push_front(объект), push_back(объект), pop_front(), pop_back(), insert(объект) – вставить объект в середину; erase (объект) – удалить объект из любого места. Свойства: «число объектов в списке».
Все замечания по реализации, выполненные для стека, действительны и для всех других структур: очереди, деки, списка.
Обратите внимание, что при определении структур данных мы нигде не требовали однотипности объектов, помещаемых в описываемую структуру. Все структуры рассматривались как некие хранилища данных, в которые может быть помещён объект любого типа. При реализации, однако, мы сталкиваемся с ограничениями языка. Язык Си, как язык с обязательным описанием данных, более всего препятствует приближению к определению. Вы не можете в Си создать стек, сам по себе – объекты, помещаемые в стек должны иметь заранее продекларированный тип (!), более того все объекты должны быть однотипными. Таким образом, вы можете создать стек целых, плавающих, стек, содержащий любые заранее объявленные структуры, стек стеков, стек очередей и т.п., только не стек, хранящий объекты любых типов.
Конечно, в Си имеется «костыль» шаблонов, но это слабое утешение: вы не можете обойти требование однотипности объектов. Возможно использование типа Variant. Но, во-первых, этот достаточно неудобный в использовании класс, поддерживается не всеми трансляторами, а во-вторых, в общем, вы опять работаете с одним типом данных – Variant.
Фактически, мы опять подошли к обсуждению вопроса хорошо это или плохо требование обязательной декларации данных. Всю историю языка Си (Си++) можно рассмотреть с точки зрения: как уйти от парадигмы «обязательного описания данных», оставшись внутри этой парадигмы. С одной стороны имеется очень жёсткая парадигма «обязательного описания данных», с другой стороны на сегодняшний день имеются сложные аппараты обхода этой парадигмы. С третьей стороны существует Basic, где такого вопроса нет по определению. Возникает вопрос (стоит ли овчинка выделки?) окупаются ли усилия по разработке достаточно сложных аппаратов ухода от этого кошмара? Не дешевле ли просто на определённом этапе отказаться от самого принципа, вставив в язык возможность объявлять переменные тип которых определяется динамически, при выполнении программы обрабатываемыми данными: тип определяемый самими данными. Интересно, понадобятся ли программисту шаблоны, если он будет иметь такой тип переменных?
Обратим внимание на то, что определение структур данных не зависит от типа данных, хранимых в этих структурах. Во всех определениях общим является то, что в определяемую структуру данных можно поместить некие объекты или извлечь эти же объекты. Даже используемые операции имеют одни и те же названия. Выделяя то общее, что содержится во всех определениях структур данных, мы естественным образом приходим к понятию контейнера.
Контейнер – некоторая структура данных, в которой можно хранить объекты. Для любого контейнера должен быть определён порядок помещения объекта в контейнер, порядок извлечения объекта из контейнера и, возможно, порядок доступа к объекту в контейнере.
Под это определение попадают все вышеописанные агрегаты данных. Но массивы и структуры, а также классы, не принято (?) рассматривать в качестве контейнеров. Эту условность следует понимать в том смысле, что при работе с массивами или структурами программист сам управляет распределением памяти под эти агрегаты, а при работе с контейнерами, по определению, программист не должен задумываться о распределении памяти. Более того, структуры и классы могут обладать своими функциями (методами), чего не требуется от контейнера.
Таким образом, единственное, что требуется от контейнера это способность быть хранилищем чего-либо. Опять-таки заметим, что однотипности помещаемых в контейнер объектов, по определению, не требуется. Если на Assemblere реализация контейнера не вызывает проблем, то в Си++ мы сразу же обнаруживаем, что реализация контейнера согласно определению невозможна, так как в контейнер могут помещаться только однотипные элементы, однако «типизированные» контейнеры возможны.
Сама идея контейнера, как ни привлекательна она для теории, для практического использования всё-таки малопригодна. Мало что-то хранить в кладовке, надо знать, что там хранится и как быстро извлечь это оттуда.
Поэтому выделяют последовательные контейнеры и ассоциативные. Последовательные контейнеры те, в которых порядок расположения объектов определяется только порядком поступления или удаления объектов. Все выше рассмотренные агрегаты данных являются последовательными контейнерами. Ассоциативные контейнеры хранят объекты в порядке, определяемом самими объектами. Обычно это сортировка объектов по некоторому задаваемому при создании контейнера алгоритму (функции сравнения). Примером ассоциативного контейнера является обыкновенное множество, если хранимые там объекты сортировать, например, по возрастанию. Фактически, при использовании ассоциативного контейнера, вы должны определить способ упорядочения объектов, помещаемых в контейнер.
Далее, обычно, определяют несколько базовых контейнеров, пользуясь которыми, строят все остальные структуры данных. Для примера, в STL [1] (Standart Template Library), базовыми последовательными контейнерами являются vector, deque и list. Такие структуры, как stack, queue, моделируются так называемыми адаптерами. Принципиально все структуры можно определить, имея только лишь один базовый контейнер, однако, скорость доступа к объектам и скорость выполнения операций (помещение в контейнер, удаление и т.д.) у специализированных контейнеров будет ниже, чем у обобщённого контейнера. Это понятно: за общность приходится платить скоростью операций.
С контейнерами связано ещё одно понятие. Если мы в контейнер можем поместить любой объект, в том числе и другой контейнер, то использование простого индекса при доступе к объектам контейнера становится затруднительно, а часто и невозможно. Всё дело в том, что теоретически контейнер не зависит от размера памяти и её строения. То есть, предполагается, что программист может помещать в контейнер объекты столько раз, сколько ему понадобится при выполнении задачи. Однако, в реальности приходится как-то учитывать ограничения по памяти. Это приводит к тому, что выполняется первичное распределение памяти под контейнер вполне определённого объёма. (В STL [1], например, первично выделяется 1К памяти, если размеры объектов, помещаемых в контейнер, не превышают 1К, или же запрашивается память только под один объект). Когда размера контейнера не хватает под следующий помещаемый элемент, выполняется перераспределение контейнера с удвоением его размера. Это приводит к тому, что адрес запрашиваемого объекта, должен определяться снова при каждом обращении к контейнеру. Это привело к понятию итератора: итератор – некий обобщённый указатель на объект, находящийся в контейнере. Работа с итераторами напоминает работу с указателями, но это не указатель.
Следует помнить, что контейнеры, встроенные пользователями типы. Отсюда следует, что работать с контейнерами следует очень аккуратно, часть обычных с точки зрения пользователя операций может быть не реализована для конкретного контейнера, или реализована, но не так, как привык понимать пользователь. Основная идея контейнеров в том, чтобы пользователь, не задумываясь, использовал контейнерные механизмы, не выясняя всякий раз хватает ли ему оперативной памяти.
Вопросы к главе 5.
1. На какие две группы можно разбить данные, существующие во время работы программы.
2. Для чего существует декларация данных?
3. Какие проверки типа данных существуют?
4. Встроенные типы данных.
5. Представление в машине символьной информации.
6. Что такое Unicode?
7. Программа перекодировки.
8. Схемы представления строк.
9. Что такое массив. Схемы представления массива в памяти.
10. Предложить программу распределения памяти под двумерный массив в Си++.
11. Что такое структура?
12. Специфические и универсальные операции.
13. Операторы new и delete в языке Cи++.
14. Агрегаты данных: перечислить известные, подробно рассказать о любом.
15. Объединение в Cи.
16. Стек и очередь.
17. Список и дека.
18. Контейнер?
19. Итератор?
20. Последовательные и ассоциативные контейнеры?
21. Тип Variant – что такое? реализация?
6. ООП – объектно – ориентированное программирование.
Всё, что мы изучали до сих пор, было основано на идее алгоритмизации. То есть, программа рассматривалась, как запись последовательности некоторых действий, которые выделялись в процессе алгоритмизации. Такое программирование называется процедурным программированием. Общая методика написания таких программ состоит в последовательном разбиении задачи на подзадачи и реализации программ, решающих возникающие подзадачи. Общая программа в таком случае разбивалась на подпрограммы, которые могли реализовываться в каком-то смысле совершенно самостоятельно. По возможности подпрограммы реализовывались так, чтобы их можно было использовать более чем в одной задаче. Подпрограмму можно использовать в другой задаче, если знать как её вызывать, как ей передать параметры и как правильно получить результаты её работы. Во всём остальном подпрограмма может рассматриваться как классический чёрный ящик.
Процедурное программирование или алгоритмизация по сути своей представляет собой выделение в задаче действий и сосредотачивает внимание программиста на действиях. Как ранее указывалось, происходит некоторое абстрагирование от данных. Однако восприятие окружающего мира мы делаем несколько не так, мы, скорее, сначала воспринимаем данные, а потом уже действия: если лес, то сначала какой лес и где, а уж потом, что там можно делать или что можно делать с этим лесом. Принципиально, мы воспринимаем мир объектами, а затем манипуляциями с этими объектами. При этом сам объект естественным образом воспринимается как чёрный ящик, мы редко задумываемся о действительном устройстве воспринимаемых объектов. Эта технология восприятия, рано или поздно должна была найти отражение в технологии программирования.
С другой стороны, чем фактически занимается программист - постоянно изобретает собственные типы данных. Число машинных типов данных изумительно мало: int, float и char. Уже тип string является агрегатом данных. При внимательном изучении мы должны будем заметить, что int, float и char всего лишь разные способы интерпретации одного и того же состояния слова. Отметим ещё раз, что сами данные не изменились: поменялись действия по получению конечного результата! Другими словами, по своей сути на нижнем уровне данные и действия не отделимы друг от друга. Мы всегда изучаем данные и операции, выполняемые над данными, при этом всё это рассматривается без отрыва друг от друга, что очень естественно.
Таким образом, принимая во внимание, скудость встроенных типов данных, программист вынужден изобретать свои собственные типы для решения поставленных задач. Однако в процедурном программировании имеется существенная разница между встроенными типами и типами, придуманными программистом. Операции с типами программиста можно осуществлять лишь подпрограммами. В процедурном программировании нет средств встройки операций с новыми типами. Реализация операций подпрограммами делают программу плохо читаемой, а, следовательно, и плохо понимаемой и, наконец, трудно сопровождаемой. Последнее, между прочим, предполагает и модификацию программы. Всё это накладывает определённые ограничения на величину кода, который в состоянии написать и отладить средний программист в заданное время. То есть, процедурное программирование подошло к своему пределу. Гради Буч [4] оценивает этот предел в 100 000 строк программного текста. Требовалось некое новшевство, которое позволило бы отлаживать более длинные программы за реальное время. Такое новшество, конечно же, появилось.
Самое интересное, что когда реально всё это произошло, то сначала никто ничего не заметил. Справедливости ради, следует отметить, что необходимые средства имеются уже в обыкновенном Си. Рассмотрим следующее объявление структуры:
struct Example {
int A; // просто переменная.
float В; // то же
int MyFisrtFunc (int s) ;
void MySecondFunc (float r); } U;
Что необычного в объявлении этой структуры: в ней объявлены функции. Правила Си не запрещают объявлять функции внутри структуры. Но согласно всё тем же правилам, вы теперь не можете вызвать функцию обычным образом, обратившись MyFisrtFunc: к любой внутренней переменной структуры необходимо обратиться через имя главной структуры - откуда: U.MyFirstFunc (... Более того, если у вас есть, например: Example T, G; То для каждого экземпляра структуры вы вызываете свой экземпляр функции, хотя функция одна - указатель на функцию разный). Но тогда вы вообще можете для каждого экземпляра структуры иметь свою внутреннюю функцию. Многие видели в подобных структурах один из недостатков языка, но Бьёрн Стауструп разглядел достоинство и, переработав концепцию классов из языка SmallTalk 4 , последовательно провёл идею классов в Cи++. В результате получился не просто язык программирования, но новое технологическое средство. Главная заслуга Страуструпа не просто в разработке нового языка, но в заострении внимания программистского мира на основных идеях: инкапсуляция, полиморфизм, наследование.
Кратко последовательно, что это такое:
Инкапсуляция (Encapsulation) механизм, объединяющий данные и код, работающий с этими данными, в единое целое, который позволяет манипулировать и данными и кодом как единым целым и позволяющим защищать и данные и код от внешнего вмешательства. Когда коды и данные объединяются вместе, создаётся некоторая новая общность, которую мы называем объект. Но тогда в языке необходимы средства, позволяющие объявлять объекты и манипулировать им. Кроме того, надо чтобы мы могли объявлять переменные типа «объект».
Внутри объекта и коды и данные могут быть закрытыми и открытыми.
Открытые члены объекта: public — доступны из любой части программы, в которой объявлен объект.
Закрытые члены объекта: private - доступны только изнутри объекта: только тем функциям, которые объявлены внутри объекта.
Шилдт Г. [10] подчёркивает, что объект является переменной определённого пользователем типа. В самом деле, у нас есть определения необходимых нам данных, и у нас определены операции с этими данными. При этом механизм обращения к этим операциям построен так, что вы не сможете обращаться к данным другого типа этими операциями.
Другой взгляд на инкапсуляцию гласит: инкапсуляция - сокрытие данных и операций над этими данными от постороннего вмешательства или ограничение доступа к некоторым частям программы и данных. Следует понимать, что грамотный программист, зная описание объекта, всегда сумеет получить доступ к любой его части, даже к тщательно «защищённой». Конечно, это будут маленькие программистские хитрости, а не законный доступ. Страуструп применяет даже более неприятный термин: жульничество, негласно предлагая понять, что инкапсуляция это хорошо.
С нашей точки зрения не следует переоценивать значение и возможности инкапсуляции, также как и недооценивать. Само по себе скрытие кодов и данных от пользователя выполняется повсеместно также как и обратное - публикация и выпуск открытого программного обеспечения. Практика показала, что хороши те программы, которые работают, а не те в которых применена инкапсуляция или объектно-ориентированное программирование. Другими словами: всё хорошо в своё время и на своём месте.
Полиморфизм: (Polymorphism) свойство, позволяющее одно и то же имя использовать для решения нескольких задач. Перегрузка функций один из примеров полиморфизма. На самом деле понятие полиморфизма гораздо шире вышеприведённого определения. Полиморфизм, следует понимать как применение одних и тех же действий/операций к разным объектам. В реальном мире большинство операций полиморфны. Примером типичной полиморфной операций может служить почти любое действие: переместить, например, можно стол, книгу, столицу и мн. др.
Другими словами: полиморфизм такое определение операций и функций, которое не зависит от типа данных. Ну, здесь как говорится: за что боролись - на то и напоролись. Хотим мы того или нет, а Си упорно дрейфует в сторону полиморфизма. Требование обязательного объявления всех переменных сильно упрощает транслятор и несколько повышает надёжность реализуемого программного обеспечения. Мы говорим несколько, поскольку от обязательного объявления переменных ошибок не стало меньше, просто пропали ошибки одного класса, но появились ошибки другого класса. Программирует по-прежнему (по всей видимости, так будет всегда) человек, а он склонен к ошибкам. Но одновременно, обязательное объявление переменных и невозможность по ходу выполнения сменить тип переменной сильно сковывает возможности программиста, точнее заставляет его использовать более хитроумные трюки. Программист всё равно своего добьётся, но теперь ему для этого потребуется больше времени и средств.
Одним из средств ухода от типа будет union. Однако это простенькое средство касается только данных. Речь идёт об операциях, о полиморфных операциях. Мы ещё раз обращаем внимание на то, что сначала последовательно проводится политика обязательного объявления, а затем тратится значительное количество усилий на преодоление возникших ограничений. Типично «коммунистическое» решение проблемы: сначала создать себе трудности, а затем мужественно их преодолевать. Для решения проблемы «ухода от типа» была разработана новая конструкция языка, достаточно сложная в реализации и применении.
Демонстрацией реально сложившейся ситуации может служить следующее заявление Страуструпа [8]: «настоящей объектно-ориентированной программой может называться только та, при трансляции которой транслятор не в состоянии определить типы объектов: реальные типы объектов появляются только при конкретном выполнении программы».
Наследование (Inheriance) средство, посредством которого один объект может использовать свойства другого. Более точно, именно использование буквального значения слова наследовать. То есть, каким образом свойства одного объекта могут рассматриваться другим объектом как свои собственные. Таким образом, объект может иметь свои собственные свойства и наследуемые свойства. Такая система позволяет строить иерархии объектов: живое существо - животное - млекопитающее - хищное - кошачьи - тигры - уссурийский тигр.
Наследование действительно сильная и новая для Си концепция. Её не было в ANSI Си. Именно идея наследования упрощает разработку и отладку сложной иерархии объектов. Более того, в большинстве визуальных сред можно явно просмотреть, что от чего наследует. Самое интересное, что наследуемое свойство или операцию можно переопределить конкретно для каждого объекта.
Итак: инкапсуляция, полиморфизм и наследование - три кита, на которых держится объектно-ориентированное программирование (ООП). Мы постарались показать, суть каждого принципа и его, так сказать, степень важности для ООП. Но каждый принцип сам по себе в отдельности не создаёт тех преимуществ, которые даёт нам ООП. Видно, что некоторые принципы (полиморфизм) являются в основном преодолением возведённых в принцип некоторых проектных решений. Однако, применяемые все три вместе эти концепции действительно дают нечто новое, то самое, что и называют ООП.
Тем не менее, в Си нельзя объявить объект, что возможно в Basic. Может и правильно, что в Си нельзя объявить объект. Едва ли к конструкции языка можно будет предъявить все те требования, которые мы привыкли предъявлять к объекту. С другой стороны понятие объект настолько обще, что едва ли его можно втиснуть в рамки алгоритмического языка.
В Cи++ можно объявить класс: class. Конструкция class во многом ведёт себя как объект. Страуструп даёт следующее определение класса: «Класс – это тип определяемый пользователем.»
Синтаксис объявления класса следующий:
class имя класса {
закрытые функции и переменные класса. public:
открытые функции и переменные класса.
) список переменных',
Обратим внимание, что объявление класса во многом похоже на объявление структуры. Но при работе с классами существует много такого, чего нет со структурами.
имя класса - фактически это имя нового, введённого программистом типа. Это имя затем везде можно применять для объявления переменных. При этом переменные приобретают тип имя класса. Синтаксис не требует обязательного задания имени класса, но вряд ли нужен класс без имени, такой класс нельзя затем будет использовать. То есть, вы сможете использовать только те переменные, которые перечислены в список_переменных.
По умолчанию всё, что вы объявляете внутри класса приобретает статус private. Открытый раздел класса начинается меткой public. Однако вы можете в любом месте прервать один раздел и начать другой, правилами это не запрещено.
Обратите внимание на следующее:
Объявление класса является логической абстракцией, которая задаёт новый тип данных или объекта. Объявление объекта создаёт физическую сущность объекта. (Или: объект занимает память! Задание типа нет.)
После объявления класса вы можете задать переменные/объекты имеющие тип этого класса:
MyClass А, С, D[3]; или
MyClass * U; U = new MyClass; ...... delete U;
Конкретных экземпляров класса мы можем иметь столько, сколько нам позволит память компьютера. Видим, что мы можем объявлять массивы классов и создавать класс динамически. Ясно, что переменные типа класс можно вставлять в структуру. В некотором смысле структуру можно рассмотреть, как класс, у которого все данные и функции открыты по умолчанию.
Что очень важно: каждый объект класса имеет собственную копию всех переменных, объявленных внутри класса!
Внутри класса можно объявить как переменные, так и функции. Переменные, обычно, называются членами (members) класса, а функции - функциями-членами (memberfunction). Так сложилось, что в визуальном программировании этим понятиям соответствуют свойства и методы. Хотя это не совсем однозначно, так как существуют свойства, которые являются некоторой функцией обязательно возвращающей значение запрашиваемого свойства.
Принято, собственно объявление класса делать в файле - заголовке. Там же, чаще всего определяются короткие функции-члены и функции inline. Обычно имя заголовка и имя класса совпадают. Функции - члены класса определяются в файле, имеющим имя одинаковое с именем класса и расширение .с или .срр. Но очень часто, в этих двух файлах совмещают объявление нескольких родственных классов.
Функции, объявленные в классе имеют доступ ко всем членам и функциям класса. Все остальные переменные, операторы и функции программы могут обращаться только к открытым членам и функциям класса. Обращение идёт через имя объекта: U.имя члена или U.имя функции - члена. Указывая конкретное имя, вы обращаетесь к конкретной реализации переменной класса, которая имеет значение свойства конкретного объекта. Также, если вы обращаетесь к функции-члену, которая в свою очередь обращается к членам класса, то и вы и функция - член работает только с членами объекта, от имени которого функция запущена. Таким образом, вы изменяете только переменные объекта, от имени которого работаете.
Пока, как видим, никакого отличия от структур, кроме того, что слово struct заменили на class и ввели две новых метки, к которым нельзя обратиться по goto. Различия появляются при работе.
Очень часто, почти всегда, создаваемые объекты должны иметь умалчиваемые значения своих членов. Вспомним, что транслятор Cи++, по умолчанию не выполняет инициализацию своих переменных. Пока дело касается простых агрегатов данных, ничего страшного, все, что нужно выполнит программист. Всё меняется, когда вы широко начинаете использовать классы. Всё дело в том, что у классов имеется закрытая часть данных, к которой рядовой программист не имеет доступа. Откуда ясно, что требуется средство инициализации членов класса, прежде всего закрытой его части. Такое средство называется конструктор класса или функция-конструктор (costructor function).
Конструктор - функция инициализирующая члены класса и, возможно, выполняющая некоторые вспомогательные действия. Конструктор может быть реализован программистом. Если программист не задал свой конструктор класса, создаётся конструктор по умолчанию. Поскольку существует перегрузка функций, никто не запрещает иметь несколько разных конструкторов класса, но все они должны быть объявлены и определены в пределах класса.
Таким образом, при создании объекта класса обязательно вызывается конструктор класса. Если имеется реализованный программистом конструктор, то вызывается он, иначе вызывается конструктор по умолчанию. Для глобальных объектов конструктор вызывается тогда, когда начинается выполнение программы. Для локальных объектов, конструктор вызывается всякий раз при выполнении оператора, объявляющего переменную. Программист сам может вызвать конструктор тогда, когда посчитает нужным. При этом конструктор вызывается немного не так как все остальные члены-функции:
имя переменной типа объект = конструктор класса;
То, что конструктор вызывается при появлении объявления объекта легко продемонстрировать, объявив глобальный класс, выводящий в конструкторе сообщение на монитор: наличие хотя бы одного экземпляра объекта сразу становится видно по сообщению.
Конструктор имеет то же имя, что и класс, частью которого он является.
Конструктор не имеет возвращаемого значения.
Невозможно получить указатель на конструктор.
Во всём остальном это обычная функция.
Функция обратная конструктору называется деструктор. Деструктор класса вызывается при удалении объекта. Локальные объекты удаляются тогда, когда они выходят из области видимости. Глобальные объекты удаляются при завершении программы.
Деструктор имеет такое же имя, как и класс, но первым символом обязательно должна быть '~' - тильда.
Деструктор не может иметь входных параметров.
Деструктор не может возвращать значения.
Невозможно получить указатель на деструктор.
Отсюда видно, что деструктор может быть только один, так как невозможно перегрузить деструктор согласно требованиям синтаксиса Cи++.
Согласно синтаксису Cи++ нет никаких ограничений на выполняемые в конструкторе и деструкторе действия. То есть там могут встречаться любые допустимые в языке операторы (за исключением, разумеется, return выражение;). Однако молчаливо предполагается, что в этих функциях выполняются только действия необходимые для конструкции и инициализации класса, или для уничтожения объекта класса. Помещение сюда не относящихся к делу действий – признак дурного тона при программировании.
Объекты класса обычные переменные, которые мы можем обычным образом использовать везде, где разрешается реализованными для данного класса операциями. Доступ к открытым членам класса осуществляется через указание объекта и точку: объект . имя открытого члена класса. Если у вас имеется указатель на объект, то вместо точки используется ->.
Точно так же, как и простые переменные вы можете выполнять присвоение переменной типа класс 1 такой же по типу переменной. При присвоении выполняется простое копирование переменных. Здесь всегда следует учесть два момента:
1. Имя класса должно быть одинаковым, а именно одинаковым должно быть имя типа. Транслятор тип переменной определяет по имени, а следовательно при разных именах, независимо от структуры класса, тип переменной будет разный и следовательно транслятор должен будет выполнять преобразование одного типа пользователя в другой, а как это делать транслятор не знает.
Выходов может быть несколько: во-первых, едва ли надо иметь два класса одной структуры с разными именами; во-вторых, можно написать собственную программу преобразования типа, что мы рассмотрим дальше; в-третьих, можно реализовать собственную программу, перегружающую операцию присвоения для этих двух типов пользователя, о чём также позже.
2. Если у вас в классе имеются прямые или косвенные указатели на области памяти или при создании элемента класса память запрашивается динамически, то после копирования объект-цель будет ссылаться на области памяти объекта-источника. Чаще всего это совершенно неправильно. В этом случае потребуется писать специальный, так называемый конструктор – копирования.
Эти особенности ещё раз подчёркивают, что мы работаем с типами определёнными самим пользователем. Транслятор многого про эти типы не знает, а следовательно все операции с этими типами должен определять сам пользователь.
Как обычные переменные объекты можно передавать в функции. При этом, по умолчанию объекты передаются по значению. Никто не запрещает передачу по ссылке, но по умолчанию объект передаётся по значению. Это приводит к тому, что при передаче в функцию объект копируется и его копия используется внутри функции, что в принципе не так страшно, так как для создания копии объекта в функцию не вызывается конструктор объекта. Однако при завершении работы функции копия объекта выпадает из области видимости и эту копию требуется удалить, для чего вызывается деструктор! Деструктор вызывается всегда. Если объект имеет так или иначе динамически полученные области памяти, то, естественно, деструктор их освободит.
Здесь необходимо отвлечься на разъяснение синтаксиса пространства имён. Как известно Cи не поддерживает понятия модуля, хотя программа чаще всего состоит из множества модулей. В связи с этим в Cи при разработке сложных программ так или иначе приходится определяться с объявлениями переменных. Программисты склонны применять одни и те же имена в разных модулях, что составляет проблему при компоновке и отладке программ. С областями видимости каждой переменной приходится разбираться. Одним из средств, облегчающим жизнь, будет пространство имён.
Пространство имён объявляется следующим образом:
namespace имя { …… }
здесь имя – имя пространства имён. Все переменные объявленные в пределах заданного пространства имён видны только в пределах объявленного имени. Таким образом, пространство имён является областью видимости. Операцией область видимости мы можем указывать именно ту переменную, которая объявлена в необходимой нам области.
имя пространства имён :: имя переменной.
Если же нам необходим доступ сразу ко всем элементам пространства имён, то можно использовать директиву using.
using namespace имя пространства имён.
Директива using может быть использована немного по-другому.
using имя пространства имён :: имя переменной – далее использовать переменную только из указанного пространства имён.
Принципиально, мы теперь имеем всё, чтобы попробовать разработать простой класс. Пусть это будет класс работы со строками, если уж работа со строками в Cи так плохо реализована.
#include <string.h>
class MyString {
char * ps; // собственно указатель на строку.
int Ls; // объём выделенной памяти под строку.
int Tl; // текущая длина строки.
int flag; // флажки строки
public:
MyString () { Ls = 256;
ps = NULL;
ps = new char [Ls];
if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }
Tl = 0; flag = 0;
return;
}
MyString (int A) {
if (A <= 0) A = 256;
Ls = A;
ps = NULL;
ps = new char [Ls];
if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }
Tl = 0; flag = 0;
return;
}
MyString (char * U) { int i, ls = strlen (U);
Ls = ls + 10;
ps = NULL;
ps = new char [Ls];
if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }
for ( i = 0; i < ls; i++) ps[i] = U[i];
for (; i < Ls; i++) ps[i] = 0x00;
Tl = ls; flag = 1;
return;
}
~MyString () { if (flag < 0) return;
delete [] ps; }
int MyString::LenMyStr () { return Tl; }
void MyString::ClearMyStr () { if (flag < 1) return;
for (int i = 0; i < Ls; i++) ps[i] = 0x00;
Tl = 0; flag = 0; return; }
bool MyString::IsMyStrEmpty () { if (flag < 1) return true; else return false; }
char * MyString::c_har () { if (flag < 1) return NULL; else return ps; }
На этом все наши знания кончаются. Мы не можем выполнить даже реальной операции присвоения.
Пусть имеется MyString A = MyString (“ Первая строка. “);
MyString B;
И мы выполняем B = A; Тогда в B мы получаем побитную копию объекта A. Далее при работе с B мы можем испортить объект A.
Ещё одно отвлечение на понятие ссылки. В Cи++ можно объявить переменную типа ссылка: тип & имя переменной = имя другой переменной того же типа. Тогда первая переменная будет содержать адрес второй переменной. Такое объявление ссылки называется объявлением «независимой» ссылки. Однако такое применение ссылок встречается редко, так как по сути своей не требуется. Сначала перечислим ограничения, относящиеся к ссылкам, а затем укажем их реальное применение.
Нельзя ссылаться на другую ссылку.
Нельзя получить адрес ссылки.
Нельзя создавать массивы ссылок и ссылаться на битовое поле.
Ссылка должна быть инициализирована до того, как стать членом класса.
Ссылки собственно были придуманы затем, чтобы упростить передачу параметров в функции.
Посмотрим следующее объявление функции:
void Func (int & A) { }; Как видим, в функцию передаётся сразу адрес переменной или ссылка.
Однако при обращении к этой функции нам не надо указывать адрес входного параметра: он просто указывается как таковой.
int G; Func (G); транслятор сам строит правильное обращение к функции и передаёт в функцию адрес переменной, а не значение.
Но самое интересное, что внутри функции мы можем обращаться к значению параметра просто по имени, не употребляя операцию разыменования. Все необходимые преобразования строит транслятор.
Функция может возвращать ссылку. В этом случае мы можем использовать функцию слева от знака присваивания!
Имеем: int & f(); тогда возможно f() = const; причём внутри функции должен быть обычный оператор return выражение. Транслятор вернёт ссылку на результат значения выражения, но само значение.
Здесь использование ссылок очень удобно и оправдано.
Основное назначение ссылок – реализация конструктора копирования.
Общий синтаксис конструктора копирования:
Имя класса (const имя класса & имя объекта)
Имеем с Cи два типа ситуаций, когда значение одного объекта передаются другому: Первая ситуация – это присваивание; вторая – инициализация. Инициализация выполняется в трёх случаях.
1. Когда в операторе объявления один объект используется для инициализации другого.
2. Когда объект передаётся в функцию в качестве параметра.
3. Когда создаётся временный объект для возврата значения функции.
Конструктор копирования годится только для инициализации объекта. Он не применяется при присвоении!
Подчеркнём: конструктор копирования не влияет на операцию присваивания.
// конструктор копирования.
MyString (const MyString & Qs) {
int i;
ps = NULL;
ps = new char [Qs.Ls];
if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }
Ls = Qs.Ls; Tl = Qs.Tl; flag = Qs.flag;
for (i = 0; i < Ls; i++) ps[i] = Qs.ps[i];
return;
}
Что здесь важно, так это то, что Qs – это правый операнд, а левый операнд передаётся в функцию неявно и тогда все внутренние переменные класса, относятся к левому операнду!
Для дальнейшей работы с классами нам необходимо освоить перегрузку операторов. Перегрузка операторов, фактически, является одним из видов перегрузки функций. Но всё не так просто. На перегрузку операторов есть определённые ограничения, которые мы обсудим по мере необходимости. Для перегрузки операторов необходимо написать оператор-функцию. Обычно, оператор функция является членом класса для которого она задана. Общая форма оператор-функции – члена класса:
возвращаемый тип имя класса :: operator знак операции (список аргументов)
{
тело функции
}
Возвращаемый тип может быть любым.
Знак операции – знак перегружаемой операции.
Список аргументов зависит от реализуемой операции.
Во время перегрузки операторов нельзя менять приоритет операций.
Во время перегрузки операторов нельзя менять число операндов.
Нельзя перегрузить операции: . :: ?
Нельзя перегружать операторы препроцессора.
Перегрузка бинарных операций:
Опять-таки необходимо внимательно следить за свойствами операции и по возможности нигде не нарушать общепринятых свойств операций. То есть, если у нас «сложение» коммутативно и при сложении не изменяются сами операнды, то наша реализация сложения должна в чём-то отвечать этим требованиям.
При перегрузке бинарных операций, левый операнд передаётся функции неявно, а правый - передаётся функции в качестве аргумента. Таким образом, все члены класса, употребляемые в функции по имени, относятся к левому операнду, а члены класса правого операнда указываются только через имя переменной.
Давайте перегрузим операцию + для нашего класса:. Это, во-первых, реализация операции конкатенация, которая не коммутативна; а во-вторых, исходные строки не должны изменяться, что естественно,итак:
// перегрузка оператора + фактически выполняем конкатенацию наших строк.
MyString MyString::operator + (MyString & Oth) {
int L, i, j;
L = LenMyStr() + Oth.LenMyStr()+1;
MyString temp = MyString (L);
if (Tl > 0) {
for (i = 0; i < Tl; i++) temp.ps[i] = ps[i];
if (temp.ps[i-1] == 0x00) i--;
}
else i = 0;
if (Oth.Tl < 1) { temp.Tl = i; return temp; }
for (j = 0; j < Oth.Tl; j++) {
temp.ps[i] = Oth.ps[j];
i++; }
temp.Tl = L; temp.flag = 1;
return temp;
}
Отметим, что мы создаём временный объект temp, что естественно, так как исходные объекты (строки) при выполнении операции не должны измениться. Важно, что сначала в temp копируется левый операнд, а затем и правый. После всех операций объект temp возвращается в качестве результата работы функции (операции). Просим не забывать, что при возврате значения будет вызван конструктор копирования.
Так реализуется бинарная операция для одинаковых типов. Но можно реализовать операцию и для разных типов. Определим операцию конкатенация для MyString и char *:
// перегрузка оператора + char *
MyString MyString::operator + (char * Ot) {
int L, i, j, k;
k = strlen(Ot);
L = LenMyStr() + k + 1;
MyString temp = MyString (L);
if (Tl > 0) {
for (i = 0; i < Tl; i++) temp.ps[i] = ps[i];
if (temp.ps[i-1] == 0x00) i--;
}
else i = 0;
if (k < 1) { temp.Tl = i; return temp; }
for (j = 0; j < k; j++) {
temp.ps[i] = Ot[j];
i++; }
temp.ps[i] = 0x00;
temp.Tl = i; temp.flag = 1;
return temp;
}
Видим, что принципиально нового здесь ничего нет, только вместо объекта справа используется строка. Такую функцию можно использовать, когда переменная типа char будет справа.
Несколько более внимательным следует быть при перегрузке оператора присвоения:
// перегрузка оператора =
MyString & MyString::operator = (MyString Oth) {
int i;
if (Ls < Oth.Ls) Redefinition(Oth.Ls);
for (i = 0; i < Oth.Ls; i++) ps[i] = Oth.ps[i];
Tl = i; flag = 1;
if (Ls > Oth.Ls) for (; i < Ls; i++) ps[i] = 0x00;
return *this;
}
Во-первых, мы возвращаем ссылку на объект, что ускоряет нам работу при возврате аргумента. Во-вторых, поскольку у нас меняется левый операнд, то его и возвращаем указанием *this. Обратим внимание, что тут используется внутренняя функция класса Redefinition.
Заменив во входном параметре класс MyString на char, мы перегрузим операцию присвоения для типа char и сможем присваивать переменным типа MyString переменные типа char:
// перегрузка оператора = char *
MyString & MyString::operator = (char * Oth) {
int i, k;
k = strlen(Oth);
if (Ls < k) Redefinition(k);
for (i = 0; i < k; i++) ps[i] = Oth[i];
if (Ls > k) for (; i < Ls; i++) ps[i] = 0x00;
Tl = k; flag = 1;
return *this;
}
Бинарные операции можно перегрузить и дружественными функциями.
Дружественной функцией называется функция, которой разрешается доступ к закрытым членам класса, но она не является членом класса. Дружественная функция объявляется сама по себе, но в объявлении класса, для которого она будет дружественной должен быть прототип функции с ключевым словом friend. Функция может быть дружественна более чем одному классу. Поскольку дружественная функция не член класса, то она не может обращаться к членам класса непосредственно, а только через указания конкретного объекта. То есть, объект должен быть передан в дружественную функцию как входной параметр. Итак, в заголовок вставляем:
friend MyString operator + (char *, MyString &);
В файл .cpp:
MyString operator + (char * Ot, MyString & Se)
{
int L, i, j, k;
k = strlen(Ot);
L = Se.LenMyStr() + k + 1;
MyString temp = MyString (L);
if (k > 0) {
for (i = 0; i < k; i++) temp.ps[i] = Ot[i];
if (Ot[i-1] == 0x00) i--;
}
else i = 0;
if (Se.Tl < 1) { temp.Tl = i; return temp; }
for (j = 0; j < Se.Tl; j++) {
temp.ps[i] = Se.ps[j];
i++; }
temp.ps[i] = 0x00;
temp.Tl = i; temp.flag = 1;
return temp;
}
Видим, что наша новая функция поразительно похожа на две предыдущие функции, перегружающие оператор +. Но, теперь у нас два входных параметра: первый параметр относится к левому операнду операции, а второй параметр к правому. Теперь в программе допускается любой порядок с-строк и строк MyString:
Пусть: MyString As, Rs; то теперь мы можем использовать следующие выражения:
As = “ простой текст “ + Rs; или As = Rs + “ простой текст. “;
Унарные операции перегружаются почти точно так же, как и бинарные. Ясно, что в случае унарной операции у нас нет входных параметров. Как будет выглядеть перегрузка инкремента: примерно следующим образом: MyString operator ++ () ;
Более интересно, что бывают префиксные и постфиксные унарные операции. Если их надо различать, то следует выполнить перегрузку для обеих форм операции:
Функция MyString operator ++() будет вызываться в случае префиксной формы, для постфиксной форсы надо объявить следующую функцию:
MyString operator ++ (int x);
Точно также можно перегрузить логические операторы и операции сравнения, только в этом случае функции-операторы будут возвращать не сами объекты, а чаще всего int или bool.
Некоторое затруднение вызывает перегрузка оператора []. Чаще всего это потому, что в реальной жизни мы не рассматриваем этот оператор как операцию. В общем, это правильно. Чаще всего программист – практик не различает операции получения данного и операцию доступа к данным. В Cи++ оператор [] считается бинарным оператором и может быть перегружен только функцией - членом.
Общая форма перегрузки этого оператора следующая:
type class_name::operator [] (int index) { }
Поскольку имеет смысл использовать индексацию, как справа, так и слева от знака присваивания, то, как правило, возвращается ссылка на возвращаемое значение. Приведём пример перегрузки оператор [] для нашего класса MyString:
char _NULL_STR = 0x00;
char & MyString::operator [] (int i)
{
if ((i < 0) | (i >= Tl)) return _NULL_STR;
return ps[i];
}
как видим, сама перегрузка тривиальна. Внимание следует обратить на то, что как-то надо решить, что делать, когда входной индекс указывает за пределы строки. Мы меняем тип, то есть по индексу возвращаем не объект класса, а символ, точнее ссылку на символ. Тогда в случае неверного индекса вернём нуль-строку. Однако мы не можем вернуть ссылку на внутреннюю переменную, а это значит, что где-то в классе должна храниться возвращаемая переменная. У нас она приведена сверху, а в реальном классе чаще всего это глобальная переменная предшествующая описанию класса. Такая перегрузка позволяет использовать индексацию как справа от знака равно, так и слева. Более того, вспоминая, что операция [] для обычного Cи++ работает как для int - индекса, так и для float - индекса, можно попытаться перегрузить операцию [] и для переменной типа float. Как это ни удивительно, перегрузка проходит, хотя не должна бы, но теперь следует предельно внимательно использовать индексы, иначе транслятор будет сообщать, что не может выбрать какую функцию использовать для реализации программы.
Наследование – это механизм, посредством которого один класс может наследовать (приобретать) свойства и методы другого класса. Класс, который наследуется, называют базовым классом (base class). Наследующий класс, называется производным классом (derived class). В базовом классе определяются свойства общие для обоих классов или для всех производных классов.
Общая форма наследования:
class имя производного класса : доступ имя базового класса { }
Спецификация доступа может быть private, public, protected
Если класс наследуется со спецификатором public – то все открытые члены базового класса становятся открытыми и в производном классе. Закрытые члены базового класса в любом случае закрыты и для производного класса.
Если базовый класс наследуется со спецификатором private – то все члены базового класса становятся закрытыми в производном классе. Однако, открытые члены базового класса по-прежнему доступны в функциях производного класса, так как они получаются для производного класса как собственные закрытые члены.
Спецификатор protected (защищённый) – предписывает, что члены класса с этим спецификатором являются закрытыми, но доступными для всех членов производных классов. Этот спецификатор также может появляться в любом месте объявления класса, но чаще всего он помещается после раздела private до раздела public.
Любой класс и базовый и производный может иметь конструкторы. Конструкторы выполняются в порядке наследования. Деструкторы в обратном порядке. Это естественно.
Как обычно, в конструкторы можно передавать параметры. Если есть необходимость передать параметры базовому классу, то этот параметр передаётся конструктору производного класса, а тот в свою очередь, передаёт параметры конструктору базового класса. Это выглядит следующим образом:
Пусть имеем class Derived : public Based { }
Вариант конструктора с параметрами для базового класса может выглядеть следующим образом:
Derived (int m, int n, float p) : Based (float p);
Класс может наследовать более одного базового класса. Выполнить можно это двояким образом. Во-первых, производный класс может быть базовым для другого производного класса, то есть исходный базовый класс будет косвенным базовым классом для производного. Таким образом, можно строить иерархии классов. Конструкторы в этом случае вызываются в порядке наследования, деструкторы в обратном порядке. Во-вторых, класс может наследовать несколько базовых классов прямо;
тогда общая форма будет следующей:
class имя производного класса : доступ имя базового класса,
доступ имя базового класса,
доступ имя базового класса, { }
В этом случае конструкторы выполняются слева направо, в порядке, задаваемом в объявлении производного класса, деструкторы, как водится в обратном порядке.
При прямом наследовании, однако, могут возникнуть проблемы. Пусть у нас есть два разных производных класса от одного базового. Пусть существует третий производный класс, использующий напрямую как базовые оба производных класса. Тогда третий производный класс косвенно наследует исходный базовый класс два раза, что приводит к противоречиям при реализации: результаты работы какого конструктора базового класса считать исходными для производного класса?
Для разрешения этой коллизии было введено понятие виртуального базового класса. Класс наследуется виртуально тогда, когда перед спецификатором доступа к базовому классу стоит ключевое слово virtual.
class имя класса : virtual доступ имя базового класса.
Тогда в ситуации, описанной выше, базовый класс наследуется только один раз! Во всём остальном виртуальный класс не отличается от обычного базового класса.
Подобная система не совсем корректна. Здесь косвенно требуется, чтобы производный класс «заранее знал», что он будет базовым для другого класса, который может наследовать от нескольких потомков одного базового класса. Естественней было сделать, чтобы по умолчанию все классы наследовались виртуально!
Для дальнейшего обсуждения нам необходимо вспомнить указатели на функцию.
Обычное объявление указателя на функцию выглядит так:
возвращаемый тип (*имя) (список параметров);
Так мы можем иметь несколько разных функций с одним и тем же списком входных параметров и одним возвращаемым типом. Для обращения к функции по указателю не требуется операции разыменования, а для получения адреса функции не требуется операции адресации: транслятор и так всё понимает. То есть, все нижеприведённые примеры законны:
void (*fn) (); void SF ();
fn = SF; fn();
Функция, вызываемая через указатель, должна вызываться абсолютно правильно: с правильными типами входных параметров и правильным типом возвращаемого значения: транслятор не будет строить никакого неявного преобразования.
Ещё одна особенность указателей: указатель на базовый класс можно использовать, как указатель на любой класс производный от базового. Обратный порядок неверен. При использовании указателя базового класса как указателя на объекты производного класса следует иметь ввиду, что этим указателем можно сослаться только на унаследованные от базового класса члены. Кроме того, при выполнении действий арифметики с такими указателями они будут изменяться на длины базового объекта.
Если в каком-либо классе некоторая функция объявлена с ключевым словом virtual, такая функция называется виртуальной. Это значит, что в производном классе она может быть переопределена. Виртуальная функция обязана быть членом класса.
Идея виртуальных функций позволяет воплотить в жизнь идею полиморфизма: один интерфейс – множество методов. Каждое переопределение виртуальной функции в производном классе определяет конкретную реализацию метода для производного класса. Виртуальная функция вызывается также как обыкновенная и с этой точки зрения ничего интересного не представляет. Но если виртуальная функция вызывается через указатель на базовый класс, то все вызовы этой виртуальной функции могут быть реализованы одним обращением для всех производных классов: транслятор, в зависимости от того на какой объект указывает указатель, строит вызов именно той версии виртуальной функции, которая связана с указываемым объектом. То есть: тип адресуемого через указатель объекта определяет, какая версия виртуальной функции вызывается. Это решение принимается во время выполнения программы! Другими словами программа становится динамически полиморфна.
Если виртуальная функция не переопределяется в производном классе, то вызывается её базовая версия. Чаще всего, однако, базовый класс рассматривается как набор функций-членов и переменных, для которых производный класс задаёт всё недостающее. В этом случае определение виртуальной функции в базовом классе будет просто ненужным, так как эта функция всё равно будет переопределяться в производном классе. Тогда транслятору следует как-то сообщить, что данная виртуальная функция имеет в классе только прототип, но не имеет определения. Таким образом – чисто виртуальная функция:
virtual тип имя функции ( список параметров) = 0;
Если класс содержит хотя бы одну чисто виртуальную функцию, то такой класс называется абстрактным классом. Поскольку нет реализации хотя бы одной из функций, то нельзя построить объект этого класса, но можно использовать этот класс для наследования.
В связи с полиморфизмом следует обсудить ещё два термина: раннее связывание и позднее связывание.
Термин «раннее связывание» относится ко всем обычным и перегружаемым функциям. Для них вся необходимая адресная информация известна уже во время трансляции и транслятор может построить очень эффективную программу, которая будет выполняться достаточно быстро, так как во время выполнения почти полностью отсутствуют действия по определению типов параметров, адресов функций и т.п. Другими словами, нет настройки программы на текущие типы. Наши типы данных не меняются во время выполнения, что позволяет сделать программу более короткой и, самое главное, более быстрой при выполнении.
Позднее связывание относится к событиям, которые происходят в процессе выполнения программы. Адрес вызываемой функции в этом случае неизвестен до запуска программы. Вызов виртуальной функции – типичный вызов позднего связывания. Только во время работы программы определяется с каким типом объектов работает программа и следовательно какие функция она будет вызывать. Программы становятся более гибкими в использовании, но более сложными, длинными и медленно работающими объектами.
Родовая функция, или шаблонная функция, или параметризованная функция: определяет базовый набор операций, который будет применяться к разным типам данных. Известно, что многие операции или алгоритмы можно применять к разным типам данных: сортировка, сравнение, перестановка и т.д. Конечно, функции, реализующие такие алгоритмы можно перегружать. Тем более, что часто вся перегрузка представляет собой только замену типа в объявлении и определении функции. Однако, нельзя ли этот процесс переложить на транслятор? Можно с использованием функции – шаблона.
template < class Type> возвращаемый тип имя функции (список входных параметров);
где Type – имя типа: имя далее используемое вместо реального типа данных.
Напишем простую шаблонную функцию перестановки элементов.
template <class Sw> void swap (Sw &x, Sw &y) {
Sw a;
a = x;
x = y;
y = a;
}
Эту функцию можно использовать с любым типом данных. Транслятор сам сгенерирует необходимый код, в зависимости от того с каким типом вызывается программа. Это означает то, что транслятор должен иметь одновременный доступ к прототипу функции (класса; шаблона) и к обращению, иначе транслятор не сможет сгенерировать всех обращений к функциям. Обратите внимание, что имя Sw везде используется вместо типа данных. Иногда говорят, что функция перегружает сама себя. Если необходимо несколько типов данных, то используется несколько имён через запятую:
template <class Sw, class Uw, class Kw> void func (Sw &x, Uw &y, Kw &d) …
Точно так же, как родовые/параметризованные функции, можно создать параметризованный класс.
template <class Ttype> class имя класса { … … }
Ttype – фиктивное имя имени типа. Реальное имя типа проявится при создании объекта класса:
Имя класса < type > имя объекта.
Точно так же можно использовать несколько имён типов, разделяя их запятыми.
Вопросы к главе 6.
1. Суть процесса разработки класса.
2. Что такое класс?
3. Инкапсуляция. Что это такое?
4. Полиморфизм. Что это такое?
5. Наследование. Что это такое?
6. Схема объявления класса в языке Cи++.
7. Что такое конструктор? Деструктор?
8. Конструктор копирования?
9. Когда вызывается конструктор?
10. Схема наследования в языке Cи++.
11. Порядок вызова конструктора и деструктора при наследовании.
12. Строение любого класса.
13. Перегрузка операторов, Что это такое?
14. Понятие ссылки в языке Cи++.
15. Перегрузка бинарных операторов.
16. Перегрузка унарных операторов.
17. Дружественные функции. Зачем они нужны?
18. Перегрузка оператора [].
20. Что такое виртуальный базовый класс?
21. Что такое виртуальная функция?
22. Что такое родовая функция?
23. Шаблоны.
24. Что такое родовой класс?
25. Что происходит при присваивании одного объекта другому?
В чём разница между перегрузкой бинарной операции членом-функции и дружественной функцией.
Зачем может потребоваться перегрузка оператора присваивания?
Что такое абстрактный класс?
Чего нельзя делать при перегрузке операций?
В чём разница между функции-членом класса, дружественной функцией и обычной функцией?
Укажите обычную схему доступа к закрытым членам класса и программы, использующей объекты класса.
Можно ли создать объекты абстрактного класса?