Методические указания Ухта 2006 удк 681 06(076) г 23

Вид материалаМетодические указания
Класс Address
Подобный материал:
1   2   3   4   5   6

Класс Address



Несколько необычно для студента звучит следующее задание:


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


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


union AD {

double * pD;

float * pF;

int * pI;

char * PAD;

};


Видим, что в объединении просто перечисляются указатели на те типы переменных, с которыми мы собираемся работать через тип Address. Всё вроде бы становится естественным, реализация прозрачна и возникает законный вопрос: "А в чём, собственно, сложность?". Реальность оказывается гораздо дальше от идеала, чем хотелось бы.

При естественной реализации класса "адрес" у вас последовательно возникают трудности и проблемы, которых вроде бы (согласно идеологии языка и его реализации (конкретный транслятор) быть вовсе не должно! Несмотря на то, что везде написано: "Вы это можете!" - вам ни в коем случае так делать не следует. Это ещё раз, в который уж, показывает, что программист вынужден сам всё проверить и сам во всём убедиться. Увы, реальность гораздо хуже того идеала, который описан в [6].


Первый законный вопрос, который возникает у всякого программиста: "Почему не использовать void *, фактически, нетипизированный указатель?" Внимательное изучение истории языка "C" показывает, что ключевое слово void появилось в языке относительно поздно. Это и понятно, писали "C" практикующие программисты, а любому практику проще ничего не писать, чем писать ключевое слово, указывающее на пустоту. Дальнейшая теоретизация языка привела к появлению void в языке. Соответственно, void * появился ещё позже. А всё, что появилось позже - лежит с краю, а всё, что лежит с краю - работает либо чуть-чуть не так, либо совсем не так, либо вовсе не работает.

Посмотрим: так ли это в нашем случае. Заметим, указатель на ничто в языке не нужен, как не теоретизируй. Причины появления в языке указателя на void очень просты - необходимо было хоть как-то упростить работу с памятью. Когда вы получаете динамически память, то функция malloc (не забудьте, что "C++" ещё нет) возвращает указатель, но ведь это должен быть типизированный указатель, а память типа не имеет. Согласно идеологии, при инициализации указателя на void он становится типизированным, то есть приобретает тип аргумента, после чего с ним можно, вроде бы, работать обычным образом. Таким образом, функция malloc возвращает особенный указатель, который может быть преобразован в указатель на любой другой тип обычным присвоением его значения другому указателю. Это единственное его законное применение. Попытка разыменования указателя на void при трансляции программы приводит к ошибке [C++ Error] ......: E2109 Not an allowed type - недопустимый тип.

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

Будем исходить из чисто практических целей. Самый дорогой ресурс в программировании это время. Программа должна работать, точнее, выдавать ожидаемые пользователем результаты, и программа должна быть сдана как можно быстрее в эксплуатацию. Не используя шаблоны, вы несколько дольше будете писать программу (просто текста к написанию будет больше), но отладите код, скорее всего быстрее. Однако экплуатация подобного кода может превратиться в проблему: если вам часто в процессе сопровождения программы встречаются непредусмотренные ранее типы данных. Надо всякий раз вставлять в класс новый тип данных, и во все методы класса, и повторять всё тестирование класса, добавляя новые проверки для нового типа. При использовании шаблонов, вам придётся гораздо чётче продумать структуру класса (выполнить объектно-ориентированную декомпозицию), код реализуется очень быстро, но его отладка может превратиться в серьёзную задачу. Зато сопровождение такого кода, скорее всего, не вызовет проблем, так как код уже предполагает смены типов данных. Более того, код может быть модифицирован без собственно его модификации. То есть, если у вас имеется класс "A", который необходимо с одной стороны изменить, а с другой он должен использоваться, так как использовался ранее, то можно реализовать класс "B", наследующий класс "A", возможно добавив в класс "A" некоторой виртуальности.

Какой путь выбрать, заранее сказать нельзя. Единственное, что на примере разработки класса можно продемонстрировать, какие возникают проблемки и проблемы. После чего выбить предполагаемое на камне, а камень положить на развилке: – "направо пойдёшь.....; налево пойдёшь....; прямо пойдёшь…".


Итак, в выбранную структуру union, лучше всего, наверное, добавить ещё два члена:

union AD {

double * pD;

float * pF;

int * pI;

char * PAD;

void * pVac;

unsigned int Dis;

};


Указателю на void мы всё-таки сможем присваивать указатели на
типы, отсутствующие в объединении. Наличие unsigned int Dis объясняется тем, что часто адрес необходимо преобразовать в целое и обратно, а нам теперь никакого преобразования и не требуется. Вместо преобразования типов мы получаем unsigned int Dis. Кроме того, наличие данного члена позволит нам просто выполнять арифметику.

Тогда начало класса выглядит следующим образом:


// из структуры этого union проглядывает основная идея: Если нам

// захочется ссылаться на некоторый экзотический тип, надо только

// добавить указатель на него в этот union и, конечно же, остальные

// программы

class Address

{

AD pointer; // собственно указатель!

int How; // длинна типа на который ссылается наш тип

public:

};


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

Однако мы могли сразу написать нечто следующее:


template <class T> class Aty {

T * p;

int How;

public:

};


Здесь класс назван Aty (type Address), для удобства обсуждения, дабы не путать нам Address и адрес (Aty). Кажется, что проблем нет, задача решена, этот класс можно использовать с любым типом. Однако на самом деле у нас остался всё тот же типизированный указатель. Сравните:

Aty <int> a; и int * a;
То есть, нам по-прежнему приходится указывать тип, но теперь в угловых скобках.

Можно, в union AD вместо void * pVac вставить T * pVac или просто добавить в AD ещё одной строкой. Это действительно даст определённые преимущества при создании объектов класса Address, когда эти объекты должны быть инициализированы адресами пользовательских типов данных, но теперь нам придётся всегда при создании объектов класса Address указывать некоторый тип, сравните:

Address a; - без T * pVac; и

Address a; - c T * pVac;

Первая строка явно предпочтительней, мало того, что она короче, но ещё у нас нет некоторого выделенного типа T, который почему-то лучше остальных типов.

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

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

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

Вернёмся, однако, к нашему классу: мы остановились на следующей структуре:


union AD {

double * pD;

float * pF;

int * pI;

char * PAD;

void * pVac;

unsigned int Dis;

};


// из структуры этого union проглядывает основная идея: Если нам
// захочется ссылаться на некоторый экзотический тип, надо только


// добавить указатель на него в этот union и, конечно же, остальные

// программы

class Address

{

AD pointer; // собственно указатель!

int How; // длинна типа на который ссылается наш тип

public:

};


Второй шаг - построение конструкторов:


Очевидно, что у нас должен быть пустой конструктор:


Address () { pointer.PAD = NULL; How = 0;}


Затем, легко реализуются четыре конструктора:


Address (double * p) { pointer.pD = p; How = sizeof(double); }

Address (float * p) { pointer.pF = p; How = sizeof(float); }

Address (int * p) { pointer.pI = p; How = sizeof(int); }

Address (char * p) { pointer.PAD = p; How = sizeof(char); }


Немного подумав, мы приходим к идее ещё четырёх конструкторов:


Address (double & u) { pointer.pD = &u; How = sizeof(double); }

Address (float & u) { pointer.pF = &u; How = sizeof(float); }

Address (int & u) { pointer.pI = &u; How = sizeof(int); }

Address (char & u) { pointer.PAD = &u; How = sizeof(char); }


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


template <class T> Address (T * p) {pointer.pVac = p; How = sizeof(T); }


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

Аналогично строится ещё один шаблон:


template <class T> Address (T & u) { pointer.pVac = &u; How = sizeof(T); }


С этими одиннадцатью конструкторами можно поэксперементировать. Пишете программу, в которой в самых разных вариантах создаёте объекты класса Address. Довольно быстро вы убедитесь, что на самом деле достаточно всего двух строк:


Address () { pointer.PAD = NULL; How = 0;}


template <class T> Address (T & u) { pointer.pVac = &u; How = sizeof(T); }


При этом класс будет работать с любым (!) ранее не предусмотренным типом. Два конструктора вместо одиннадцати – впечатляет. На самом деле две строки вместо одиннадцати, поскольку по шаблону транслятор сгенерирует по конструктору на каждый встреченный при трансляции тип. Для того, чтобы убедиться в этом рекомендуется завести какой-либо пользовательский тип и проверить работу с ним:

struct Ks {

good G;

double flash;

} GH;

Address Afi(GH);

или Afi = Address(GH);


Воодушевлённым таким упехом можно переходить к шагу три:


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


template <class T> Address & operator = (T & u) { pointer.pVac = &u;

How = sizeof(T);

return (Address &)this; }


С классом рекомендуется реализовать все разумные операции, причём как можно естественней. Перегрузка операций сравнения или программы сравнения при реализации не вызывают трудностей. В тексте приведены перегрузки операций "==", "!=" и "<". Обратите внимание, что мы пользуемся тем, что указатели можно сравнивать между собой, но можно было просто сравнивать переменную pointer.Dis. Точно также нет трудностей и при перегрузке операций арифметики (см. текст.)

Далее всё несколько менее радужно. Зная адрес, хотелось бы иметь возможность получения значения переменной. Для этого стоит перегрузить одну из разрешённых к перегрузке операций. Операция присвоения у нас уже использовалась, арифметические операции используются по своему назначению, поэтому предлагается использовать операции сдвига - "<<" и ">>".

Таким образом: пусть имеется Address a; double u, d = 10.6;

после: a = d; u = a >> u; значение u также должно быть 10.6, то есть u должно быть равно d. Можно перегружать и операцию "<<=", что совершенно не влияет на релизацию


Итак, возможным кодом может быть следующий:

double operator >> (double u) { return *pointer.pD; }


Тогда ясно как перегрузить эту операция ещё для трёх типов данных:


float operator >> (float u) { return *pointer.pF; }

int operator >> (int u) { return *pointer.pI; }

char operator >> (char u) { return *pointer.PAD; }


Это правосторонние операции, то есть операции - Address operator Type.

Левосторонние операции (Type operator Address) для этих четырёх типов также запросто перегружаются дружественными функциями:


friend double operator << (double & u, Address a);

friend float operator << (float & u, Address a);

friend int operator << (int & u, Address a);

friend char operator << (char & u, Address a);


Ещё имеется простая возможность перегрузить операции сдвига для типов Address:


Address & operator >> (Address & a);

Address & operator << (Address & a);


Однако при их перегрузке нам придётся явно использовать член класса How, нам неизвестна реальная длинна пересылаемых данных, наивно полагаем, что длина данных равна How. Мы не можем разыменовать void * pVac, а, следовательно, не можем написать шаблон возврата значения для любого типа.

На самом деле причина гораздо более глубокая, чем это кажется. Дело не в возможности разыменования void *. Дело в том, что для получения некоего значения некоторой структуры, класса, нам необходимо знать внутреннее строение этой самой структуры, класса. Что считать значением объекта типа TForm? Ну хорошо, это достаточно сложный объект. Что считать значением объекта типа AnsiString - строку, которую объект AnsiString содержит или структуру, описывающую эту строку? Мы не знаем заранее внутреннего строения всех предполагаемых к обработке типов данных, мы даже списка этих типов не можем составить. Именно по этой причине мы не можем написать универсальную программку, перегружающую операции ">>" и "<<".

Тем не менее, мы можем сделать побитную (в нашем случае побайтную) копию объекта, надо только знать длину объекта. Следует иметь в виду, что существуют объекты, для которых технология побитных копий неприемлема, а программа перегрузки операции "=" может быть недоступна или неизвестна. Единственное, что мы можем сделать это простую копию памяти, однако и тут есть небольшая проблема: длина копируемой области. Здесь то нам и пригодится член класса How. Будем копировать столько, сколько там указано.

Нам необязательно именно таким образом решать проблему размера
копируемой области. Можно оставить это на усмотрение программиста. Дело в том, что по одному байту мы всегда можем копировать операциями "<<"/">>". Тогда, программист сам решает что , когда, сколько и куда копировать.

Принимая, тем не менее, что в How хранится реальная длина объекта, нам потребуется возможность туда её записывать, а затем и получать. Это совсем нетрудно сделать функциями Get/Set:


int LenType () { return How; }

void SetLen (int f) { if (f > 0) How = f; }


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


template <class T> Address (T & u, int k) { pointer.pVac = &u;

if (k > 0) How = k;

else How = sizeof(T); }


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


operator double * () { return pointer.pD; }

operator float * () { return pointer.pF; }

operator int * () { return pointer.pI; }

operator char * () { return pointer.PAD; }

operator unsigned int () { return pointer.Dis; }


Безусловно, хочется написать шаблон на программку преобразования типа, например:


template <class T> operator T () { return (T) pointer.pVac; }


Синтаксически правильно, а, по сути, абсолютно неверно. Попробуйте этот шаблон использовать для преобразования в AnsiString. Причина неприемлимости этого шаблона та же, по которой мы не могли написать шаблон на перегрузку операции "=": мы не знаем внутренней структуры преобразуемого типа. Для примера реализована программа преобразования типа Address в тип AnsiString, с такой программкой очень удобно распечатывать адреса переменных в диалоговых окнах. Обратите внимание сколь причудливым образом это сделано (см. текст). Разумеется, реальные преобразования одного типа в другой могут потребовать достаточно нетривиальных действий.

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

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

Учитывая специфику нашего класса, мы реализовали два метода: копирование памяти и сравнение памяти. Кроме того реализована очистка объекта класса значением NULL. (вообще говоря, значение NULL зависит от реализации транслятора, хотя чаще всего это ноль).