3. Классы. (3 час.) Функции-члены и данные-члены. Интерфейсы и реализация. Конструкторы и инициализация. Конструктор без параметров (по умолчанию). Деструкторы и очистка. Конструктор копирования. Указатель this. Статические члены: функции и данные. Указатели на члены. Структуры и объединения. Константные члены-функции и константные объекты.
Структуру классического Си можно рассматривать, как предшественницу класса. Объединяя программный код с данными, структура может служить элементарной формой класса.
Рассмотрим реализацию понятия даты с использованием struct для того, чтобы определить представление даты date и множества функций для работы с переменными этого типа:
struct date
{
int day; // день
int month; // месяц
int year; // год
} today;
void set_date(date*, int, int, int);
void next_date(date*);
void print_date(date*);
// ...
Функции-члены и данные-члены. Никакой явной связи между функциями и типом данных в этом примере нет. Такую связь можно установить, описав функции как члены структуры. Эти функции могут действовать на данные, содержащие в самой структуре. По умолчанию при объявлении структуры ее данные и функции являются общими, то есть, у объектов типа структура нет ни инкапсуляции, ни защиты данных:
struct date {
int day;
int month;
int year;
void set(int, int, int);
void get(int*, int*, int*);
void next();
void print();
};
Функции, описанные таким образом, называются функциями - членами и могут вызываться только для специальной переменной соответствующего типа с использованием стандартного синтаксиса для доступа к данным - членам структуры. Например:
date today; // сегодня
date my_burthday; // мой день рождения
void f()
{
my_burthday.set(30,12,1950);
today.set(18,1,1985);
my_burthday.print();
today.next();
}
Поскольку разные структуры могут иметь функции члены с одинаковыми именами, при определении функции члена необходимо указывать имя структуры, связывая их с помощью оператора видимости ::
void date::next()
{
if ( ++day > 28 )
{
// делает сложную часть работы
}
}
В функции члене имена членов могут использоваться без явной ссылки на объект. В этом случае имя относится к члену того объекта, для которого функция была вызвана.
Интерфейсы и реализация. Описание date в предыдущем примере дает множество функций для работы с date, но не указывает, что эти функции должны быть единственными для доступа к объектам типа date. Это ограничение можно наложить, используя вместо struct class:
class date
{
int day;
int month;
int year;
public:
void set(int, int, int);
void get(int*, int*, int*);
void next();
void print();
};
Метка public делит тело класса на две части. Имена в первой, закрытой части (private), могут использоваться только функциями членами. Вторая, открытая часть, составляет интерфейс к объекту класса. Обе эти части составляют реализацию объекта. Struct - это просто class, у которого все члены общие, поэтому функции члены определяются и используются точно так же, как в предыдущем случае. Описание date в предыдущем примере дает множество функций для работы с date, но не указывает, что эти функции должны быть единственными для доступа к объектам типа date. Это ограничение можно наложить, используя вместо struct class:
class date
{
int day;
int month;
int year;
public:
void set(int, int, int);
void get(int*, int*, int*);
void next();
void print();
};
Личная часть класса не обязательно должна следовать в начале определения класса. Для обозначения отношения элементов структуры к личной части в произвольном месте определения класса перед ними можно использовать служебное слово private. Стандартным является размещение элементов данных в личной части, а функций-элементов - в общей части класса. Тогда закрытая личная часть определяет данные объекта, а функции-элементы общей части образуют интерфейс объекта "к внешнему миру" (методы).
Конструкторы и инициализация. Использование для обеспечения инициализации объекта класса функций вроде set_date() (установить дату) неэлегантно и чревато ошибками. Поскольку нигде не утверждается, что объект должен быть инициализирован, то программист может забыть это сделать, или (что приводит, как правило, к столь же разрушительным последствиям) сделать это дважды. Есть более хороший подход: дать возможность программисту описать функцию, явно предназначенную для инициализации объектов. Поскольку такая функция конструирует значения данного типа, она называется конструктором. Конструктор распознается по тому, что имеет то же имя, что и сам класс. Например:
class date
{
date(int, int, int);
};
Когда класс имеет конструктор, все объекты этого класса будут инициализироваться. Если для конструктора нужны параметры, они должны даваться:
date today = date(23,6,1983);
date xmas(25,12,0); // сокращенная форма (xmas - рождество)
date my_burthday; // недопустимо, опущена инициализация
Часто бывает хорошо обеспечить несколько способов инициализации объекта класса. Это можно сделать, задав несколько конструкторов. Например:
class date
{
int day;
int month;
int year;
public:
// ...
date(int, int, int); // день месяц год
date(char*); // дата в строковом представлении
date(int); // день, месяц и год сегодняшние
date(); // дата по умолчанию: сегодня
};
Конструкторы подчиняются тем же правилам относительно типов параметров, что и перегруженные функции. Если конструкторы существенно различаются по типам своих параметров, то компилятор при каждом использовании может выбрать правильный:
date today(4);
date july4("Июль 4, 1983");
date guy("5 Ноя");
date now; // инициализируется по умолчанию
Конструктор без параметров (по умолчанию). Размножение конструкторов в примере с date типично. При разработке класса всегда есть соблазн обеспечить "все", поскольку кажется проще обеспечить какое-нибудь средство просто на случай, что оно кому-то понадобится или потому, что оно изящно выглядит, чем решить, что же нужно на самом деле. Последнее требует больших размышлений, но обычно приводит к программам, которые меньше по размеру и более понятны. Один из способов сократить число родственных функций - использовать параметры по умолчанию. В случае date для каждого параметра можно задать значение по умолчанию, интерпретируемое как "по умолчанию принимать: today" (сегодня).
class date
{
int day;
int month;
int year;
public:
// ...
date(int d = 0, int m = 0, int y = 0);
date(char*); // дата в строковом представлении
};
date::date(int d, int m, int y)
{
day = d ? d : today.day;
month = m ? m : today.month;
year = y ? y : today.year;
// проверка, что дата допустимая
// ...
}
Когда используется значение параметра, указывающее "брать по умолчанию", выбранное значение должно лежать вне множества возможных значений параметра. Для дня day и месяца month ясно, что это так, но для года year выбор нуля неочевиден. К счастью, в европейском календаре нет нулевого года . Сразу после 1 г. до н.э. (year=-1) идет 1 г. н.э. (year=1).
Объект класса без конструкторов можно инициализировать путем присваивания ему другого объекта этого класса. Это можно делать и тогда, когда конструкторы описаны. Например:
date d = today; // инициализация посредством присваивания
По существу, имеется конструктор по умолчанию, определенный как побитовая копия объекта того же класса. Если для класса X такой конструктор по умолчанию нежелателен, его можно переопределить конструктором с именем X(X&).
Деструкторы и очистка. Определяемый пользователем тип чаще имеет, чем не имеет, конструктор, который обеспечивает надлежащую инициализацию. Для многих типов также требуется обратное действие, деструктор, чтобы обеспечить соответствующую очистку объектов этого типа. Имя деструктора для класса X есть X() ("дополнение конструктора"). В частности, многие типы используют некоторый объем памяти из свободной памяти, который выделяется конструктором и освобождается деструктором. Заметим, что в Си++ для этого используются операторы new и delete. Пример конструктора и деструктора объекта date:
class date
{
int *day;
int *month;
int *year;
public:
date(int d, int m, int y)
{
day = new int;
month = new int;
year = new int;
*day = d ? d : 1;
*month = m ? m : 1;
*year = y ? y : 1;
};
...
date()
{
delete day;
delete month;
delete year;
};
};
Конструктор копирования. Как правило, при создании объекта вызывается конструктор, за исключением случая, когда объект создается как копия другого объекта этого же класса, например:
date date2 = date1;
Однако имеются случаи, в которых создание объекта без вызова конструктора осуществляется неявно:
- формальный параметр - объект, передаваемый по значению, создается в стеке в момент вызова функции и инициализируется копией фактического параметра;
- результат функции - объект, передаваемый по значению, в момент выполнения оператора return копируется во временный объект, сохраняющий результат функции.
Во всех этих случаях транслятор не вызывает конструктора для вновь создаваемого объекта:
- date2 в приведенном определении;
- создаваемого в стеке формального параметра;
- временного объекта, сохраняющего значение, возвращаемое функцией.
Вместо этого в них копируется содержимое объекта-источника:
- date1 в приведенном примере;
- фактического параметра;
- объекта - результата в операторе return.
При наличии в объекте указателей на динамические переменные и массивы или идентификаторов связанных ресурсов, такое копирование требует дублирования этих переменных или ресурсов в объекте-приемнике, как это было сделано выше в операции присваивания. С этой целью вводится конструктор копирования, который автоматически вызывается во всех перечисленных случаях. Он имеет единственный параметр - ссылку на объект-источник:
class string
{
char *Str;
int size;
public:
string(string&); // Конструктор копирования
};
// Создает копии динамических переменных и ресурсов
string::string(string& right)
{
Str = new char[right->size + 1];
strcpy(Str,right->Str);
}
Конструктор копирования обязателен, если в программе используются функции-элементы и переопределенные операции, которые получают формальные параметры и возвращают в качестве результата такой объект не по ссылке, а по значению.
Указатель this. В функции - члене на данные - члены объекта, для которого она была вызвана, можно ссылаться непосредственно. Например:
class X
{
int m;
public:
int readm() { return m; };
};
X aa;
X bb;
void f()
{
int a = aa.readm();
int b = bb.readm();
// ...
};
В первом вызове члена readm() m относится к aa.m, а во втором - к bb.m.
Указатель на объект, для которого вызвана функция-член, является скрытым параметром функции. На этот неявный параметр можно ссылаться явно как на this. В каждой функции класса x указатель this неявно описан как
X *this;
и инициализирован так, что он указывает на объект, для которого была вызвана функция член. this не может быть описан явно, так как это ключевое слово. Класс x можно эквивалентным образом описать так:
class X
{
int m;
public:
int readm() { return this->m; };
};
При ссылке на члены использование this излишне. Главным образом this используется при написании функций членов, которые манипулируют непосредственно указателями.
Статические члены: функции и данные. Класс - это тип, а не объект данных, и в каждом объекте класса имеется своя собственная копия данных, членов этого класса. Однако некоторые типы наиболее элегантно реализуются, если все объекты этого типа могут совместно использовать (разделять) некоторые данные. Предпочтительно, чтобы такие разделяемые данные были описаны как часть класса.
Иногда требуется определить данные, которые относятся ко всем объектам класса. Типичные случаи: требуется контроль общего количества объектов класса или одновременный доступ ко всем объектам или части их, разделение объектами общих ресурсов. Тогда в определение класса могут быть введены статические элементы - переменные. Такой элемент сам в объекты класса не входит, зато при обращении к нему формируется обращение к общей статической переменной с именем
имя_класса::имя_элемента
Доступность ее определяется стандартным образом в зависимости от размещения в личной или общей части класса. Сама переменная должна быть явно определена в программе и инициализирована:
#include
class dat
{
int day,month,year;
int month;
int year;
static dat *fst; // Указатель на первый элемент
dat *next; // Указатель на следующий элемент
public:
void show(); // Просмотр всех объектов
dat(); // Конструктор
dat(); // Деструктор
};
dat *dat::fst = NULL; //Определение статического элемента
void dat::show()
{
dat *p;
for (p=fst; p !=NULL; p=p->next)
{ /* вывод информации об объекте */ }
};
//------ Конструктор - включение в начало списка ------- dat::dat()
{ /* ... */ next = fst; fst = this; }
//------ Деструктор - поиск и исключение из списка ------dat::dat()
{
dat *&p = fst; // Ссылка на указатель на
// текущий элемент списка
for (; p !=NULL; p = p->next)
if (p = this) // Найден - исключить и
{
p = p->next;
return; // и выйти
};
};
В данном примере используется ссылки на указатель текущего элемента списка (неявный указатель на указатель текущего элемента списка).
Статическими могут быть объявлены также и функции-элементы. Их "статичность" определяется тем, что вызов их не связан с конкретным объектом и может быть выполнен по полному имени. Соответственно в них не используются неявный указатель на текущий объект this. Они вводятся, как правило, для выполнения действий, относящихся ко всем объектам класса. Для предыдущего примера функция просмотра всех объектов класса может быть статической:
class list
{
...
static void show(); // Стaтическая функция просмотра
} // списка объектов
static void list::show()
{
list *p;
for (p = fst; p !=NULL; p=p->next)
{ ...вывод информации об объекте... };
}; void main()
{
...
list::show(); // Вызов функции по полному имени
};
Например, для управления задачами в операционной системе или в ее модели часто бывает полезен список всех задач:
class task
{
// ...
task *next;
static task *task_chain;
void shedule(int);
void wait(event);
// ...
};
Описание члена task_chain (цепочка задач) как static обеспечивает, что он будет всего лишь один, а не по одной копии на каждый объект task. Он все равно остается в области видимости класса task, и "извне" доступ к нему можно получить, только если он был описан как public. В этом случае его имя должно уточняться именем его класса:
task::task_chain;
В функции члене на него можно ссылаться просто task_chain. Использование статических членов класса может заметно снизить потребность в глобальных переменных.
Статическими могут быть объявлены также и функции - члены. Их "статичность" определяется тем, что вызов их не связан с конкретным объектом и может быть выполнен по полному имени. Соответственно в них не используются неявный указатель на текущий объект this. Они вводятся, для выполнения действий, относящихся ко всем объектам класса. Для предыдущего примера функция просмотра всех объектов класса может быть статической:
class list
{
...
// Стaтическая функция просмотра списка
static void show();
}
static void list::show()
{
list *p;
for (p = fst; p !=NULL; p=p->next)
{ ...вывод информации об объекте... };
};
void main()
{
...
list::show(); // Вызов функции по полному имени
};
Указатели на члены. Можно брать адрес члена класса. Получение адреса функции члена часто бывает полезно. Однако, на настоящее время в языке имеется дефект: невозможно описать выражением тип указателя, который получается в результате этой операции. Поэтому в текущей реализации приходится жульничать, используя трюки. Что касается примера, который приводится ниже, то не гарантируется, что он будет работать. Используемый трюк надо локализовать, чтобы программу можно было преобразовать с использованием соответствующей языковой конструкции, когда появится такая возможность Этот трюк использует тот факт, что в текущей реализации this реализуется как первый (скрытый) параметр функции члена:
struct cl
{
char* val;
void print(int x) { cout << val << x << "\n"; };
cl(char* v) { val = v; }
}; // “фальшивый” тип для функций членов:
typedef void (*PROC)(void*, int);
main()
{
cl z1("z1 ");
cl z2("z2 ");
PROC pf1 = PROC(&z1.print);
PROC pf2 = PROC(&z2.print);
z1.print(1);
(*pf1)(&z1,2);
z2.print(3);
(*pf2)(&z2,4);
};
Во многих случаях можно воспользоваться виртуальными функциями там, где иначе пришлось бы использовать указатели на функции.
C++ поддерживает понятие указатель на член: cl::* означает "указатель на член класса cl". Например:
typedef void (cl::*PROC)(int);
PROC pf1 = &cl::print; // приведение к типу ненужно
PROC pf2 = &cl::print;
Для вызовов через указатель на функцию-член используются операции . и ->. Например:
(z1.*pf1)(2);
((&z2)->*pf2)(4);
Структуры и объединения. По определению struct - это просто класс, все члены которого общие, то есть
struct s { ... };
есть просто сокращенная запись
class s { public: ... };
Структуры используются в тех случаях, когда скрытие данных неуместно.
Именованное объединение определяется как структура, в которой все члены имеют один и тот же адрес. Если известно, что в каждый момент времени нужно только одно значение из структуры, то объединение может сэкономить пространство. Например, можно определить объединение для хранения лексических символов Cи - компилятора:
union tok_val
{
char* p; // строка
char v[8]; // идентификатор (максимум 8 char)
long i; // целые значения
double d; // значения с плавающей точкой
};
Сложность состоит в том, что компилятор, вообще говоря, не знает, какой член используется в каждый данный момент, поэтому надлежащая проверка типа невозможна. Например:
void strange(int i)
{
tok_val x;
if (i) x.p = "2";
else x.d = 2;
sqrt(x.d); // ошибка если i != 0
};
Кроме того, объединение, определенное так, как это, нельзя инициализировать. Например:
tok_val curr_val = 12; // ошибка: int присваивается tok_val'у
является недопустимым. Для того, чтобы это преодолеть, можно воспользоваться конструкторами:
union tok_val
{
char* p; // строка
char v[8]; // идентификатор (максимум 8 char)
long i; // целые значения
double d; // значения с плавающей точкой
tok_val(char*); // должна выбрать между p и v
tok_val(int ii) { i = ii; };
tok_val() { d = dd; };
};
Это позволяет справляться с теми ситуациями, когда типы членов могут быть разрешены по правилам для перегрузки имени. Например:
void f()
{
tok_val a = 10; // a.i = 10
tok_val b = 10.0; // b.d = 10.0
};
Когда это невозможно (для таких типов, как char* и char[8], int и char, и т.п.), нужный член может быть найден только посредством анализа инициализатора в ходе выполнения или с помощью задания дополнительного параметра. Например:
tok_val::tok_val(char* pp)
{
if (strlen(pp) <= 8)
strncpy(v,pp,8); // короткая строка
else
p = pp; // длинная строка
};
Использование конструкторов не предохраняет от такого случайного неправильного употребления tok_val, когда сначала присваивается значение одного типа, а потом рассматривается как другой тип. Эта проблема решается встраиванием объединения в класс, который отслеживает, какого типа значение помещается:
class tok_val
{
char tag;
union
{
char* p;
char v[8];
long i;
double d;
};
int check(char t, char* s)
{ if (tag != t) { error(s); return 0; }; return 1; };
public:
tok_val(char* pp);
tok_val(long ii) { i=ii; tag='I'; };
tok_val(double dd) { d=dd; tag='D'; };
long& ival() { check('I',"ival"); return i; };
double& fval() { check('D',"fval"); return d; };
char*& sval() { check('S',"sval"); return p; };
char* id() { check('N',"id"); return v; };
};
Конструктор, получающий строковый параметр, использует для копирования коротких строк strncpy(). strncpy() похожа на strcpy(), но получает третий параметр, который указывает, сколько символов должно копироваться:
tok_val::tok_val(char* pp)
{
if (strlen(pp) <= 8)
{ // короткая строка
tag = 'N';
strncpy(v,pp,8); // скопировать 8 символов
}
else
{ // длинная строка
tag = 'S';
p = pp; // просто сохранить указатель
};
};
Тип tok_val можно использовать так:
void f()
{
tok_val t1("short"); // короткая, присвоить v
tok_val t2("long string"); // длинная строка, присвоить p
char s[8];
strncpy(s,t1.id(),8); // ok
strncpy(s,t2.id(),8); // проверка check() не пройдет
};
Константные члены-функции и константные объекты. Иногда требуются исключения из правил доступа, когда некоторой функции или классу требуется разрешить доступ к личной части объекта класса. Это согласуется с тем принципом, что сам класс определяет права доступа к своим объектам "со стороны". К средствам контроля доступа относятся объявления функций-членов константными (const). В этом случае они не имеют права изменять значение текущего объекта, с которым вызываются. Заголовок такой функции при этом имеет вид
void dat::put() const
{ ... }
Аналогично можно определить константные объекты:
const class a {...} value; Контейнерные классы. Реализация событийного управления. Класс Y называется контейнерным по отношению к классу X, если объект или массив объектов класса Y являются данными-членами класса X. При этом можно говорить, что объект класса X и объект(ы) класса Y находятся в отношении «включает в себя», а объект(ы) класса Y «входи(я)т» в объект класса X. Указанные отношения объектов классов X и Y существенно отличаются от отношения «является», так как объекты этих классов могут не иметь общих свойств. Будем различать два вида реализации контейнерных классов:
объекты класса X являются владельцами контейнерных объектов класса Y,
объекты класса Y являются самостоятельными объектами, включаемыми в объекты класса Х в качестве контейнерных.
В первом случае (X владеет Y) возможны 2 реализации:
class X { class Y {
... ...
Y Object_Y; Y(void);
... ...
}; };
Особенности первой реализации состоят в том, что
так как Object_Y представлен в классе X как объект, то нет необходимости управлять его созданием в конструкторах и уничтожением в деструкторах класса X,
в классе Y обязательно наличие контструктора без параметров или вообще отсутствие конструктора (используется конструктор по умолчанию),
для заполнения свойств контейнерного объекта Object_Y в классе Y должны присутствовать соответствующие методы, доступные классу X.
class X { class Y { ... };
...
Y *Object_Y;
...
X(...) { ...; Object_Y = new Y; ...; };
X() { ...; delete Object_Y; ...; };
...
};
Особенности второй реализации состоят в том, что
так как Object_Y представлен в классе X как указатель на объект класса Y, то при создании объекта X, сам контейнерный объект не создается. Конструкторы класса X должны вызывать нужный конструктор контейнерного объекта, а деструктор – уничтожать.
для изменения свойств контейнерного объекта по указателю Object_Y в классе Y должны по-прежнему присутствовать соответствующие методы, доступные классу X.
Во втором случае (Y самостоятельный объект) объекты класса X не должны никаким образом управлять процессами создания и уничтожения контейнерных объектов, поэтому реализация контейнерной связи может быть только с использованием указателей.
class X { class Y { ... };
...
Y *Object_Y;
void AddY(Y* y) { ...; Object_Y = y; ...; };
...
};
X x;
Y y;
x.Add(&y);
В классе X при этом должен существовать открытый метод установки связи между самостоятельными объектами классов X и Y.
Пусть некоторое событие в объекте класса X, под которым мы понимаем любое изменение свойств объекта, влечет за собой событие в контейнерном объекте класса Y. При любой реализации контейнерной связи генерация события в классе Y является тривиальной, так как объекты X знают об связанных с ними объектах Y. Достаточно лишь наличие в классе Y метода, реализующего это событие, доступного из метода в классе X, инициирующего это событие через контейнерный объект класса Y, описанного в классе X. в классе X: в классе Y:
Event_X: Event_Y(...)
Object_Y.Event_Y(параметры события)
или
Object_Y->Event_Y(параметры события)
Больший интерес представляет случай, когда инициатором события является контейнерный объект класса Y, при котором изменяются свойства объекта-контейнера класса X. Для случая независимой реализации объектов, решение данной задачи заключается в том, что данные-члены обоих классов теперь должны включать взаимные ссылки классов в виде указателей.
class X;
class Y {
...
X *Object_X;
void Event_Y_src(...) { ...; Object_X->Event_X_dst();...; };
void Event_Y_dst(...) { ...; };
...
};
class X {
...
Y *Object_Y;
void Event_X_src(...) { ...; Object_Y->Event_Y_dst();...; };
void Event_X_dst(...) { ...; };
X* AddY(Y* y) { ...; Object_Y = y; ...; return(this); };
...
};
X x;
Y y;
y.Object_x = x.Add(&y);
|