Область видимости элементов класса. Инкапсуляция
Все элементы класса (и поля, и методы) можно разделить на три группы в зависимости от их области видимости:
1) public:
открытые элементы, к которым имеется доступ из любого участка программы, использующей класс
2) private:
скрытые элементы, которыми можно пользоваться только при реализации методов этого класса. Больше они нигде не доступны.
3) protected:
защищенные элементы можно использовать при реализации методов этого класса и его потомков (т.е. область видимости немного шире, чем у элементов из группы private)
Возможность управления областью видимости каждого элемента класса по отдельности позволяет воплотить на практике принцип инкапсуляции.
Поля класса обычно объявляют в ограниченной области видимости private или protected (в расчёте на будущих наследников). Таким образом, прикладная программа, использующая класс, не может записать в поля любые значения, среди которых могут оказаться и недопустимые. Доступ к полям надёжнее выполнять через методы, которые не позволят занести в поля недопустимые значения. Отсюда и термин "инкапсуляция" – поля как бы заключены в капсулу из защищающих их методов.
Возможны и исключения из этого правила – в некоторых случаях поля не нуждаются в защите через методы. Этот вопрос нужно решать индивидуально, исходя из специфики класса и его полей.
Многие методы класса делают открытыми, чтобы ими можно было пользоваться, за исключением тех, которые носят служебный характер.
Теперь, наконец, пришло время сказать, в чём заключается отличие описаний класса с использованием типов class и struct.
Область видимости по умолчанию для типов class и struct.Если при описании класса область видимости каких-либо элементов явно не задана, то для типа class подразумевается private, а для типа struct – public. Можно и так сказать, что тип класса более надёжно защищает свои элементы (даже от невнимательности программиста).
Методы-константы.Можно считать эту возможность ещё одним (дополнительным) способом защиты полей класса. Она заключается в том, что при описании методов, которые не изменяют значений полей класса, можно добавить в заголовок спецификатор const – он помещается в конец заголовка после списка формальных параметров. Наличие этого спецификатора заставляет компилятор выполнить дополнительную проверку, чтобы убедиться, что данный метод действительно не меняет значения ни одного из полей, т.е. он относится к методам-константам.
Спецификатор const никак не влияет на функциональность метода, однако, его применение рекомендуется для повышения надёжности программного кода.
Создание и использование объектов в С++.Как уже было сказано, объекты − это переменные заданного класса, поэтому работа с ними выполняется, как и с переменными других типов.
Глобальные объекты создаются во время запуска программы. Для того чтобы создать глобальный объект, необходимо его объявить вне функции main и других функций:
К таким объектам можно обратиться из любой точки программы. Главное − следить за изменениями данных объектов, т.к. неуправляемое изменение глобальных объектов может вести к трудно находимым ошибкам.
Автоматические объекты создаются при входе в функцию и удаляются при выходе из неё. Использование данного объекта возможно только в пределах этой функции.
Управляемые объекты создаются и удаляются с помощью операторов new и delete. При создании эти объекты помещаются в "кучу", поэтому очень важно следить за тем, чтобы каждому new соответствовал оператор delete, иначе будут утечки памяти.
Для обращения к полям и методам объектов используются символы "." и "->”. Точка используется, когда к объекту обращаются по имени. Операция "->" используется когда обращаются к объекту через указатель на объект.
Теперь мы получили достаточно информации, чтобы перейти к примерам.
3.2.3 Первые примеры
В качестве самого первого примера приведём класс для работы с комплексными числами. Правда, в стандартной библиотеке С++ уже реализован шаблонный тип complex (для его использования нужно подключить заголовочный файл <complex>), который предоставляет полный набор операций с комплексными числами. Так что пример опять носит чисто учебный характер. Мы и назовём класс немного иначе − complex_t , и не будем стремиться к реализации всех методов для работы с комплексными числами.
В качестве первого примера класс хорош тем, что выделение полей не составляет никакого труда – это действительная (re) и мнимая (im) части комплексного числа. И в защите они не нуждаются, поскольку могут принимать любое действительное значение. Поэтому, пожалуй, оформим класс в виде структуры, содержащей только открытые элементы. Методы в класс будем добавлять по мере дальнейшего изложения – пока пусть будут самые простые.
// Пример 3.1 – реализация структуры "комплексное число"
#include <iostream>
struct complex_t
{ // Все элементы public по умолчанию
double re, im;
void show() const; // вывод комплексного числа
// задание значений для re и im
void set(double _re, double _im);
}; // про точку с запятой в конце не забыли
// реализация методов
void complex_t :: show() const { // "::" означает принадлежность классу
std::cout<<re<<"+i*"<<im<<std::endl;
}
void complex_t :: set(double _re, double _im) {
re=_re; im=_im;
}
int main() { // демонстрация структуры complex_t
complex_t a, b; complex_t *p=new complex_t;
a.set(1,1); a.show(); // задали значения через метод set
// а теперь обратились напрямую к полям
b.re=2; b.im=-3; b.show();
p->set(4,8); p->show(); // используем указатели
}
Следующий пример, тоже простой, демонстрирует защиту полей. На этот раз реализуем класс "Отрезок времени" (назовём его time_range), который содержит в качестве полей часы и минуты. На этот раз поля требуют защиты, поскольку значение минут не должно превышать 59. Будем считать, что мы реализуем просто отрезок времени (не время суток!), поэтому значение часов не будем приводить к отрезку [0,23], но и это поле нуждается в защите.
// пример 1.3.2 - реализация класса "отрезок времени"
#include <iostream>
class time_range {
private:
unsigned int hour, minute;
public:
void set (unsigned int _hour, unsigned int _minute);
void show () const; // вывод отрезка времени
};
void time_range::set (unsigned int _hour, unsigned int _minute) {
hour = _hour; minute = _minute;
if (minute > 59) { // исправляем недопустимые значения минут
hour += minute / 60;
minute %= 60;
}
}
void time_range::show() const {
std::cout << hour << ":" << minute << std::endl;
}
int main() {
time_range t;
t.set(1, 75);
t.show();
}
При обнаружении некорректного значения минут (больше 59) метод set автоматически корректирует время. Возможны и другие варианты обработки этой ситуации, в данном случае мы исходили из того, что и некоторые функции стандартной библиотеки С++ при некорректных данных могут возвратить результат, исправив исходные данные. Обработка исключительных ситуаций будет подробно рассмотрена в следующей главе.
Отметим, что оба класса, представленные в качестве примеров, пока очень несовершенны. Будем постепенно улучшать их качество, осваивая новые понятия ООП. Первым делом добавим конструкторы и деструкторы.
Конструкторы и деструкторы.
Одна из главных целей С++ заключается в том, чтобы сделать использование объектов таким же простым, как и использование встроенных типов данных (int, float и т.д). Переменные простых типов можно инициализировать при создании. Хотелось бы иметь такую же возможность и при создании объектов (экземпляров класса). С этой целью используются специальные методы класса − конструкторы.
Конструктор – метод класса, вызываемый автоматически при создании объекта, который выполняет функцию инициализации полей начальными значениями и, возможно, некоторые другие действия (выделение памяти, открытие файлов и т.д.) Имя конструктора должно совпадать с именем класса, данный метод не возвращает значения (при этом слово void не пишется). Часто в классе объявляется несколько конструкторов с различными параметрами. Типичный случай − три конструктора: конструктор по умолчанию (без параметров), конструктор с параметрами и конструктор копирования, который в качестве параметра принимает объект такого же класса и создаёт его копию.
В предыдущих примерах мы обошлись вообще без написания конструкторов. Это не очень хорошо, поскольку при создании локального объекта его поля (имеющие простые типы), не инициализируются и содержат случайные значения. Можете провести небольшие эксперименты с программным кодом примера 1.1 – добавьте глобальные объекты типа complex_t и убедитесь с помощью метода show(), что локальные и глобальные объекты инициализируются по-разному.
Отметим, что если в классе не определено конструкторов, один из них (конструктор копирования) компилятор добавляет автоматически.
В качестве примера напишем три конструктора для структуры "комплексное число". В данном случае действие конструктора копирования будет совпадать с тем, что вставляет компилятор, но мы приводим этот пример в учебных целях.
Конструктор по умолчанию (конструктор без параметров). При реализации конструкторов можно использовать различный синтаксис для инициализации полей класса. Во-первых, можно присваивать значения полям в теле конструктора:
complex_t() {
re = 0; im = 0;
}
Такой способ имеет следующие недостатки. Во-первых, если поле объявлено со спецификатором const, то ему не удастся присвоить значение. Во-вторых, поля объектных типов могут инициализироваться дважды: перед входом в тело конструктора (значениями по умолчанию) и внутри тела.
Более правильный синтаксис заключается в использовании списка инициализации, как показано в примере:
complex_t() : re(0), im(0) {}
При этом тело конструктора получилось пустым.
Конструктор с параметрами:
complex_t(double _re, double _im) : re(_re), im(_im) {}
Допускается называть параметры конструктора так же, как поля класса, в этом случае конструктор будет выглядеть так:
complex_t(double re, double im) : re(re), im(im) {}
Встречая в списке инициализации запись re(re), компилятор понимает, что нужно инициализировать поле re параметром re.
Конструктор копирования (в данном классе не обязателен):
complex_t(const complex_t &other) : re(other.re), im(other.im) {}
Деструктор – это метод, автоматически вызываемый при удалении объекта. Деструкторы имеют такое же имя, как и имя класса, только перед именем ставится знак ~ (тильда). Деструкторы играют важную роль, если класс работает с динамической памятью (использует операторы new и delete).
Деструктор для класса complex_t не требуется, но мы создадим пустой деструктор, чтобы продемонстрировать, как он объявляется:
~complex_t(){}
Конструктор нельзя вызывать как метод класса, используя объект, т.к. объект уже создан и проинициализирован. Деструктор вручную вызвать можно, но обычно делать этого не стоит, т.к. при правильном программировании деструкторы вызываются автоматически.
Указатель this
В каждом методе класса (кроме статических методов, которые будут рассмотрены далее) доступен специальный неявный параметр this, который содержит адрес текущего объекта (то есть объекта, для которого был вызван этот метод). Указатель this неявно добавляется при работе с любым полем − например, re и this->re в теле методов класса complex_t будут означать одно и то же.
Рассмотрим пример, в котором используется указатель this. Пусть требуется в классе complex_t реализовать метод plus1, который прибавляет единицу к действительной части числа и при этом возвращает ссылку на изменённое значение. Такую функцию можно будет использовать как часть выражения. Возвращать ссылку более эффективно, чем новое значение, так как не тратится время на создание копии.
struct complex_t {
...
complex_t& plus1() {
re++;
return *this;
}
};
int main() { // демонстрация структуры complex_t
complex_t a(2, 3), c;
c = a.plus1();
a.show(); cout << endl; c.show();
system("pause"); return 0;
}
В данном примере метод plus1 написан целиком внутри тела класса complex_t − для небольших методов это допустимо.
Перегрузка операций
При разработке классов можно переопределять стандартные операции. В этом случае для каждой операции пишут специальный метод-оператор, который называется operator операция. Например, вместо метода plus1 в предыдущем примере можно было бы переопределить операцию '++', реализовав метод operator++. Переопределение операций стоит использовать с осторожностью, чтобы не потерять общепринятый смысл знаков операций.
В языке C++ возможна перегрузка почти всех операций, при этом имеются следующие ограничения: нельзя изменить приоритет операции, количество аргументов и порядок выполнения (слева направо или справа налево).
В каждом классе уже имеется операция = (операция присваивания), которая выполняет простое копирование всех полей одного объекта в другой. Если требуется при присваивании выполнить более сложные действия (обычно они требуются при работе с динамической памятью), её можно переопределить. Другие операции переопределяются, если в этом есть необходимость.
Напомним, что все операции в C++ делятся на унарные, бинарные и тернарные. Унарные операции обычно переопределяются как методы без параметров, бинарные требуют одного параметра (при этом в качестве одного из параметров предполагается текущий объект). Можно также переопределять операции в виде отдельных функций (а не методов класса) − тогда бинарная функция будет иметь один параметр, а бинарная − два. Поясним сказанное примерами.
Определим постфиксную и префиксную формы операции ++.
struct complex_t {
...
// префиксная операция ++
complex_t& operator ++() {
re++;
return *this;
}
// постфиксная операция ++
complex_t operator ++(int) {
complex_t tmp(*this);
re++;
return tmp;
}
...
};
Чтобы компилятор мог отличить префиксную форму операции ++ от постфиксной, во втором варианте в операторный метод добавляется дополнительный параметр типа int – так требуется по стандарту. Для этого фиктивного параметра можно даже имя не указывать, что мы и сделали.
Реализация префиксной формы операции ++ практически совпадает с реализацией функции plus1. Поясним код для постфиксного варианта. Нам нужно, чтобы в выражении наподобие c = a++; в объект c записалось старое значение объекта a, а не новое. Для этого в операторной функции мы сохраняем старое значение во временной переменной tmp, которую и возвращаем в качестве результата. Заметим, что будет ошибкой возвращать ссылку, поскольку локальная переменная tmp уничтожается при выходе из функции.
Приведём пример перегрузки операции вычитания.
struct complex_t {
...
complex_t operator - (const complex_t &other) const {
return complex_t(re - other.re, im - other.im);
}
...
};
int main() { // демонстрация структуры complex_t
complex_t a(3, 4), b(2, 1);
complex_t c = a - b;
c.show();
system("pause"); return 0;
}
Обратите внимание на два спецификатора const в объявлении функции operator -. Первый const относится к параметру other, второй − к неявному параметру this (то есть функция не будет изменять текущий объект).
В заключение этого раздела приведём ещё один пример класса (немного посложнее) – на этот реализуем класс массива с заданными границами индекса.
Класс - массив в стиле языка Pascal (с произвольной левой границей)
//
#include <iostream>
class pas_array{
// тип int легко заменить на любой другой
typedef int type_of_data;
int left, right; // левая (нижняя) и правая (верхняя) границы индекса
type_of_data *data; // указатель на начало массива
public:
pas_array(int left, int right) : // параметры - границы индекса
left(left), right(right),
data (new elem_type [right-left+1])
{}
pas_array& operator = (const pas_array &other) { // операция =
if (this != &other) {
if (right - left < other.right - other.left) {
delete[] data;
data = new type_of_data[right - left + 1];
}
left = other.left;
right = other.right;
for (int i = 0; i <= right - left; i++)
data[i] = other.data[i];
}
return *this;
}
pas_array(const pas_array &other) : // конструктор копирования
left(other.left), right(other.right) {
data = new type_of_data[right - left + 1];
for (int i = 0; i <= right - left; i++)
data[i] = other.data[i];
}
~pas_array() {
delete[] data;
}
// операция индексирования
type_of_data & operator [] (int index) {
return data[index - left];
}
};
int main() {
pas_array a(-3, 3);
for (int i = -3; i <= 3; i++)
a[i] = i;
pas_array b(a);
pas_array c(1, 2);
c = b;
for (int i = -3; i <= 3; i++)
std::cout << c[i] << " ";
}