
- •Конспект лекций по курсу «Объектно-ориентированное программирование»
- •1. Введение
- •Эффективные типы, определяемые пользователем
- •Копирование объектов класса
- •Перегрузка операторов
- •Дружественные функции
- •Наследование
- •Виртуальные функции
- •Абстрактные классы
- •Включение
- •Шаблоны
- •Стандартная библиотека
Копирование объектов класса
Объекты можно копировать.
Существует 2 вида копирования объектов:
инициализация при помощи копирования
stack s2=s1;
присваивание
s2=s1;
По умолчанию копия объекта содержит копию каждого члена. Почленное копирование обычно является неправильным при копировании объектов, имеющих ресурсы, управляемые парой конструктор/деструктор (например, для класса стека с динамическим выделением памяти во время создания стека и уничтожением этой памяти во время уничтожения стека).
Если это не то, что требуется для класса, можно реализовать подходящее поведение, написав для копирования собственную(ые) функцию(и). Для случая 1) это будет копирующий конструктор, а для случая 2) –копирующее присваивание.
class stack{
int cap;
int top;
char *s;//копирование по умолчанию создает
//стеки-сиамскиеблизнецы
public:
stack(int capacity=10):cap(capacity),top(0),
s(new char[capacity]){}
~stack(){delete[] s;}
stack(const stack&);
stack& operator=(const stack&);//returns value for s1=s2=s3;
};
Использование:
stack a(100),c;a.push(‘a’);
stack b=a;с=a;
Для правильного копирования объектов данного класса копирующие функции нужно определить так:
#include <cstring>//for memcpy
stack::stack(const stack& st)
:cap(st.cap),top(st.top),s(new char[st.cap]){
//for(int i=0;i<cap;++i)s[i]=st.s[i];
memcpy(s,st.s,cap);
}
stack& stack::operator=(const stack&st){
if(this!=&st){
delete[] s;
s=new char[cap=st.cap];
//for(int i=0;i<cap;++i)s[i]=st.s[i];
memcpy(s,st.s,cap);
top=st.top;
}
return *this;
}
Копирующий конструктор и копирующее присваивание отличаются из-за того, что копирующий конструктор инициализирует «чистую» память, а копирующее присваивание должно правильно работать с уже созданным объектом (защита от присваивания самому себе, инициализация и копирование новых элементов).
Перегрузка операторов
Для того, чтобы перемножить две переменные типа doubleи сложить с третьей типаdouble, мы можем написать:
x+y*z
Для переменных cx,cy,czопределенного нами ранее типаcomplexаналогичные действия мы выполняем так:
complex cx,cy,cz;
cy.mul(cz);
cx.add(cy);
Если же мы запишем
cx+cy*cz
то компилятор выдаст ошибку, т.к. встроенные операции «+» и «*» применимы только ко встроенным типам.
Однако C++ позволяет определить смысл многих операций (в том числе сложения и умножения) для определяемых пользователем типов, например:
class complex{
public:
...
complex operator+(complex);
complex operator*(complex);
private:
double re,im;
};
complex complex::operator +(complex a)
{
return complex(re+a.re,im+a.im);
}
complex complex::operator *(complex a)
{
return complex(re*a.re-im*a.im, re*a.im+im*a.re);
}
Если cy и cz имеют тип complex, то cy+cz означает cy.operator+(cz).
Теперь мы можем записывать действия над комплексными числами в форме, близкой к общепринятой:
complex c1(r1,i1),c2(r2,i2);
c2=с2+c1*complex(2,3);
В определении оператора мы указываем, какие действия нужно произвести и что возвратить.
Выполняются обычные правила приоритета операций, поэтому в приведенной операции сначала выполняется умножение, затем сложение.
Операторные функции.Можно объявить функции, определяющие смысл следующих операторов:
+ - * / % = < > += -= *= ++ -- , -> [] () new delete
и других (всего 42).
Следующие операторы не могут быть определены пользователем:
:: (разрешение области видимости)
. (выбор члена класса или структуры)
.* (выбор через указатель на член класса)
?: (тернарный оператор)
Допускается переопределять существующие операторы, но нельзя создавать новые (например ** для степени).
Бинарные и унарные операторы.
Операторы– функциональные компоненты выражения. Аргументами операторов являютсяоперанды. В зависимости от количества операндов операторы бывают бинарными(два операнда):
Например: a+b, a*=b, a<b, a[b], a&b, a&&b
и унарными(один операнд)
Например: -a, --a, &a,a++
Унарные операторы, в свою очередь, делятся на префиксные(-a, ++a) ипостфиксные(a++).
Есть также один тернарныйоператор,a?b:c, но переопределять его действие нельзя.
Оператор на основе своих операндов вычисляет результат, который может использоваться в качествеоперандадля последующих операторов:
a*b+c>d(((a*b)+c)>d)
Порядок, в котором выполняются операторы, определяется их приоритетом. Например, приоритет оператора * выше, чем оператора +.
Кроме того, некоторые операторы изменяют(или могут изменять) свои операнды
++a,a++,a*=b,a=b
а некоторые – не изменяют
a+b,a-b, -a.
Правила опеределения операторных функций
Для любого бинарногооператора @ выражениеaa@bbинтерпретируется либо как
а) нестатическая функция-член с одним аргументом
aa.operator@(bb)
либо как
б) функция-не-член с двумя аргументами
operator@(aa,bb)
Если определены обе функции, то для выяснения того, какую из них использовать, применяется механизм разрешения перегрузки (см. перегрузка функций). Например:
class X{
public:
void operator+(int);
X(int);
};
void operator+(X,X);
void operator+(X,double);
void f(X a)
{
a+1; //a.operator+(1)
1+a; //::operator+(X(1),a)
a+1.0;//::operator+(a,1.0)
}
Унарные префиксные операторы
Для любого префиксного оператора @ выражение @aaинтерпретируется либо как
а) нестатическая функция-член без аргументов
aa.operator@()
либо как
б) функция-не-член с одним аргументом
operator@(aa)
Унарные постфиксные операторы
Для любого постфиксного оператора @ выражение aa@ интерпретируется либо как
а) нестатическая функция-член с аргументом типа int
aa.operator@(int)
либо как
б) функция-не-член следующего вида:
operator@(aa,int).
Если определены обе функции, то для определения того, какую (возможно, никакую) из них использовать, применяется механизм разрешения перегрузки. Пример:
class X{//члены
X* operator&();//префиксный унарный оператор & (чей-то адрес)
X operator&(X); //бинарный оператор & (И)
X operator++(int); //постфиксный инкремент
X operator&(X,X); //ошибка: 3 операнда
X operator/(); //ошибка: унарный оператор /
};
//функции-не-члены
X operator-(X); //префиксный унарный -
X operator-(X,X); //бинарный-
X operator--(X&,int); //постфиксный декремент
X operator-(); //ошибка: отсутствует операнд
X operator-(X,X,X); //ошибка: три операнда
X operator%(X); //ошибка: унарный оператор %
Тип комплексных чисел
Рассмотрим использование перегрузки операторов на примере создания класса комплексных чисел.
Конструирование. Мы должны обеспечить создание объекта-комплексного числа следующими способами:
complex c1,//1
c2(1),
c3=2,
c4(3,4),
c5=complex(5,6);
Для этого мы определяем набор конструкторов:
class complex{
public:
complex():re(0),im(0){}//1.0
complex(double real):re(real),im(0){}
complex(double real,double imag):re(real),im(imag){}
private:
double re,im;
}
В данном случае имеет место перегрузка функций (несколько функций с одинаковым именем complex). Используя аргументы по умолчанию, мы можем добиться того же результата, определив всего один конструктор:
complex(double real=0,double imag=0):re(real),im(imag){}//1
Операторы, модифицирующие операнды. Мы хотим, чтобы добавление значения к комплексному числу выглядело в стилеC++:
c1+=c4;//2
c2+=7;
Для этого мы определяем оператор +=. У нас есть выбор: определить его в классе или как функцию-не-член класса. Так как нам нужен доступ к представлению класса (re,im), то объявляем в классе:
complex& operator+=(const complex& a);//2
Обратите внимание на объявление аргумента const complex& a. В этом случае формальный параметр aявляется ссылкой на фактический параметр. При этом в функцию передается адрес фактического параметра (4 байта). Альтернативой было бы сделать объявление следующим образом:
complex& operator+=(complex a);
В этом случае фактический параметр aинициализируетсязначениемформального параметра (в данном случае 16 байт). По соображениям эффективности выбираем первый вариант. Возникает вопрос: почему бы для сокращения передаваемой в функцию информации использовать не ссылку, а указатель? То есть:
complex& operator+=(const complex *a);
Но дело в том, что данная оператор-функция вызовется только в случае, когда правый операнд – адрес, т.е.:
c1+=&c4;
Но такой синтаксис – это не то, что мы хотели бы использовать. Поэтому в качестве формального параметра применяем все-таки ссылку.
Для добавления действительного числа объявляем функцию-член класса
complex& operator+=(double a);//2
Определяем эти функции-операторы в .cpp – файле.
complex& complex::operator+=(const complex& a)//2
{
re+=a.re;//добавление действительной части аргумента к действительной части данного объекта
im+=a.im;//добавление мнимой части
return *this;//возврат значения
}
В данной функции выполняется два действия: 1) выполняется операция добавления и 2) выполняется возврат значения. Возврат значения нужен, чтобы результат оператора += можно было использовать в качестве операнда. Например:
с1=(с2+=с3)+7;
Результатом оператора += должно быть значение его левого операнда (в примере –c2)послетого, как к нему добавлен правый операнд (в примереc3). Когда вызывается функцияcomplex::operator+=, левый операнд для нее доступен как объект, для которого она вызвана. Таким образом, эта функция должна вернуть значение самого объекта. Это можно сделать, разименовав указательthis.
Почему же мы возвращаем ссылку, а не объект? Потому, что для типа intследующее выражение должно увеличить значение переменнойa, а не копии переменнойa:
(a+=b)++;// после того, как кaдобавилиb, увеличитьaна 1.
Это же должно быть справедливо и для проектируемого нами типа complex, а для этого нужно возвращать ссылку на левый операнд.
Реализация второго оператора += несколько проще, так как добавление идет только к действительной части комплексного числа:
complex complex::operator+=(double a){
re+=a;
return *this;
}
Операторы, не модифицирующие операнды. Теперь сделаем так, чтобы можно было выполнять сложение комплексных чисел с действительными и друг с другом следующим образом:
c1=3+c5;//3
c1=c5+3;
c1=c4+c5;
Для этого определим оператор +. Опять у нас есть выбор: определить его как член класса или как не-член-класса. В пользу последнего решения есть два довода. Во-первых, мы не можем определить оператор-член-класса так, чтобы он был вызван для случая 3+c5, т.к. левый операнд не является объектом. Во-вторых, для выполнения сложения не требуется иметь доступ к представлению класса complex. Итак, определяем функции-не-члены класса:
complex operator+(const complex& a, const complex& b);//3.1
complex operator+(const complex& a, double b);//3.2
complex operator+(double a, const complex& b);
Эти три формы оператора учитывают все интересующие нас комбинации типов операндов. Определения этих функций используют оператор += для добавления к локальной переменной:
complex operator+(const complex& a,const complex& b){
complex r=a;
return r+=b;
}
complex operator+(const complex& a,double b){
complex r=a;
return r+=b;
}
complex operator+(double a,const complex& b){
complex r=b;
return r+=a;
}
И опять, как и в случае с +=, сложение complex с double проще, чем сложение двух complex.
Сравнение. Мы должны иметь возможность сравнивать комплексные числа друг с другом и сdoubleв виде, принятом для встроенных типов, например:
c2==c4;//4
c2==8;
3==c2;
Для этого определим оператор ==. Как и в случае с оператором +, мы не можем определить оператор == как функцию-член, т.к. она не может быть вызвана для случая 3==с2. Следовательно, это должна быть функция-не-член. Сравнение двух комплексных чисел:
bool operator==(const complex& a,const complex& b);//4.2
Модификаторы constиспользуются для указания того, что функция не изменяет аргументы. Этой функции требуется доступ к представлению класса (re,im), однако представление класса имеет модификаторprivate: и к нему нельзя получить доступ из этой функциинапрямую. Проблема решается введением функций доступа для извлечения действительной и мнимой частей:
public:
double real()const{return re;}
double imag()const{return im;}
Эти функции не только объявлены, но и определены внутри класса. Этим самым мы сделали их встраиваемыми в место вызова по соображениям эффективности (тот же эффект дает использование ключевого слова inline). Теперь мы можем определить оператор сравнения:
bool operator==(const complex& a,const complex& b){
return a.real()==b.real()&&a.imag()==b.imag();
}
Для выполнения сравнения complex с double и double с complex мы могли бы определить еще два оператора ==:
bool operator==(const complex& a,const double& b){
return a.real()==b && a.imag()==0;
}
bool operator==(const double& a,const complex& b){
return a==b.real() && 0==b.imag();
}
Следует заметить, что в этих функциях выполняются те же действия, что и в первом варианте оператора==, но вместо действительной части используется сам аргумент типа double, а вместо мнимой части – 0. То есть сравнение производится с комплексным числом, у которого вместо действительной части – аргумент типа double, а мнимая часть равна 0. Если бы мы могли из действительного числа получить комплексное указанным способом, мы могли бы работу двух последних операторов == возложить на первый оператор ==. Оказывается, такой механизм преобразования у нас уже имеется – это конструктор complex, в случае, когда он вызывается с одним аргументом типа double. Поэтому определять две последние функции operator== мы не будем. А для того, чтобы их работа была выполнена первым оператором ==, нам не нужно ничего определять дополнительно. Компилятор сам в нужных местах вызовет конструктор, чтобы из double получить complex. Например:
c2==c4;//operator==(c2,c4)
c2==8;//operator==(c2,complex(8))
3==c2;//operator==(complex(3),c2)
Ввод-вывод. И, наконец, мы хотим выводить комплексные числа принятым в C++ способом:
std::cout<<c1<<c2<<c3;
,
Для этого определим оператор вывода для комплексного числа:
std::ostream& operator<<(std::ostream& os, const complex& a)
{
return os<<'('<<a.real()<<','<<a.imag()<<')';
}
Теперь запись std::cout<<c1<<c2<<c3; эквивалентна следующему:
operator<<(operator<<(operator<<(std::cout,c1),c2),c3);
Класс Matrix
Перегрузку операторов вызова функции и индексирования рассмотрим на примере класса Matrix. Этот класс предоставляет динамически размещаемые двумерные массивы.
class Matrix{
public:
Matrix(size_t,size_t);
Matrix(const Matrix&);
~Matrix();
private:
size_t d1,d2;
int* m;
};
Определение класса содержит количество элементов d1по первой иd2по второй размерности массива. Типsize_t– это тип значения, возвращаемого операторомsizeof. В конструкторе выделяется память для хранения всех элементов данного массива, а базовый адрес выделенной памяти является инициализирующим значением для указателяm.
Matrix::Matrix(size_t dim1,size_t dim2)
:d1(dim1),d2(dim2),m(new int[size()]){}
Для хранения элементов двумерной матрицы могут быть использованы несколько вариантов организации памяти. В данном случае используется одномерный массив. Достоинства такой организации: простота удаления-освобождения памяти. Недостатки: для доступа к элементу используется умножение (см. ниже).
Деструктор выполняет delete[] для освобождения памяти, выделенной оператором new[] в конструкторе.
Matrix::~Matrix(){delete[] m;}
В конструкторе используется вспомогательная функция-член size(), возвращающая количество элементов массива. Так как эта функция используется только внутри класса, мы помещаем ее в раздел private:
private:
size_t size() const {return d1*d2;}
Создание матрицы размером 2x3 выглядит следующим образом:
Matrix m(2,3);
Мы хотим заполнять матрицу в цикле, например, так:
for(size_t i=0;i<m.dim1();++i)
for(size_t j=0;j<m.dim2();++j)
m(i,j)=rand();
Здесь функции-селекторы dim1() иdim2() возвращают размеры массива по соответствующим размерностям. Эти функции определены в пределах класса:
size_t dim1() const {return d1;}
size_t dim2() const {return d2;}
Обратите внимание на то, в какой форме записано обращение к элементу матрицы – m(i,j). К имени объекта справа приписаны скобки, что означает вызов функции с именемmс двумя параметрами(i,j). Оказывается, вызов функции – это тоже операторC++. Чтобы записьm(i,j)действительно означала обращение к элементу матрицы, мы должны перегрузить этот оператор. Оператор (), а также операторы =, [] и -> можно перегружать только как нестатические функции-члены класса. Это ограничение гарантирует, что левый операнд указанных операторов будет объектом. Перегружаем оператор ():
int& Matrix::operator()(size_t dd1,size_t dd2){//1
assert(dd1<dim1());
assert(dd2<dim2());
return m[dd1*dim2()+dd2];
}
int Matrix::operator()(size_t dd1,size_t dd2)const{//2
assert(dd1<dim1());
assert(dd2<dim2());
return m[dd1*dim2()+dd2];
}
Мы сделали два перегруженных оператора вызова функции, отличающиеся только const-модификатором и типом возвращаемого значения. Это сделано для того, чтобы в разных случаях вызывалась подходящая версия этого оператора. Так, при заполнении матрицы (см. выше) компилятор выберет первый вариант оператора.
Тела обеих функций operator() одинаковы: выполняется проверка на допустимость индексирующих значений, после чего вычисляется позиция элемента в одномерном массивеm. Затем первая функция возвращаетссылкуна данный элемент, а вторая –значение данного элемента. Именно возврат ссылки, а не значения позволяет изменять значение элемента массива, записывая m(i,j)=rand().
Теперь напишем функцию print, печатающую содержимое любой матрицы, переданной ей в качестве параметра:
void print(const Matrix& m){
for(size_t i=0;i<m.dim1();++i){
for(size_t j=0;j<m.dim2();++j){
std::cout<<m(i,j)<<',';
}
std::cout<<std::endl;
}
}
Параметр функции printявляется ссылкой на константную матрицу. Так как функцияprintне может изменить матрицу, компилятор выберет версию оператора () с модификаторомconst. Возвратзначенияобходится дешевле, чем возвратссылки, т.к. ссылку требуется разименовывать, а значение – нет, а объем передаваемой памяти одинаков (4 байта).
int* Matrix::operator[](size_t dd1)const{
assert(dd1<dim1());
return &m[dd1*dim2()];
}
Вызов: m[3][4]
(m.operator[](3))[4]
Matrix& Matrix::operator=(const Matrix& t){
if(this!=&t){
delete[] m;
m=new int[(d1=t.dim1())*(d2=t.dim2())];
copy(t);
}
return *this;
}
Итераторы
Итераторы не являются частью языка. Это концепция, используемая в программировании. Итераторы предоставляют способ последовательного доступа ко всем элементам составного объекта, не раскрывая его внутреннего представления.
Давайте посмотрим, как мы заполняли массив matrixна предыдущем занятии:
for(size_t i=0;i<m.dim1();++i)
for(size_t j=0;j<m.dim2();++j)
m(i,j)=rand();
Для этого мы организовали два вложенных цикла. При этом нам понадобилось использовать две индексные переменные, вызывать две функции, а также следить за тем, чтобы переменная, наращиваемая до dim1() (это i), была первым индексом вm(i,j), а переменная, наращиваемая доdim2() (здесь этоj), была вторым индексом.
Однако перебор элементов можно организовать с помощью специального объекта – итератора. Имея в своем распоряжении итератор, мы можем сделать две вещи:
а) получить доступ к текущему элементу,
б) перейти к следующему элементу.
Изменим класс Matrix, добавив в него определение итератора и две функции:
typedef int* iterator;
iterator begin()const{return m;}
iterator end()const{return m+size();}
Функции begin() иend() возвращают значения, которые являются «границами» матрицы для итератора. Теперь заполнение массива мы можем организовать так:
for(Matrix::iterator p=m.begin();p!=m.end();++p)
*p=rand();
Здесь с помощью итератора pмы
а) получаем доступ к текущему элементу путем разименования указателя,
б) переходим к следующему элементу, производя инкремент указателя.
Следуя идиоме итерационного перебора, напишем функцию, печатающую матрицу:
void print_iter(const Matrix& m){
for(Matrix::iterator p=m.begin();p!=m.end();++p)
std::cout<<*p<<',';
}
Следуя эффективной, идиоматичной схеме, программист избегает обычных ловушек, таких как ошибка, связанная с выходом за границы массива.
Теперь сделаем итератор для перебора элементов матрицы в обратном направлении. По прежнему для перехода к следующему элементу (элементу с меньшим адресом) будем использовать оператор инкремента. Теперь нам не подходит указатель, так как при вызове ++pитератор должен идти в сторону уменьшения. Поэтому нам придется определить класс итератора:
Так как этот класс используется исключительно с классом Matrix, то определим этот класс внутри классаMatrix
class Matrix{
...
class reverse_iterator{
public:
reverse_iterator(int *p):_p(p){}
int& operator*(){return *_p;}
void operator++(){--_p;}
bool operator!=(reverse_iterator r)const{return _p!=r._p;}
private:
int *_p;
};
...
};
Функции-члены класса Matrix, возвращающие граничные значения для данного итератора:
reverse_iterator rbegin()const{return m+size()-1;}
reverse_iterator rend()const{return m-1;}
Теперь перебор в обратном порядке выглядит так:
for(Matrix::reverse_iterator r=m.rbegin();r!=m.rend();++r)
*r=rand();
Недостатки приведенной концепции: необходимо определять по две дополнительные функции-члены контейнера (begin,end) для каждого типа итератора.
Другая идиома для итератора, не имеющая этих недостатков:
class m_iter{
public:
m_iter(Matrix& m):_begin(m.m),_end(m.m+m.size()){}
void First(){_p=_begin;}
bool NotDone()const{return _p!=_end;}
int& CurrentItem()const{return *_p;}
void Next(){++_p;}
private:
int *_begin, *_end, *_p;
};
Использование:
m_iter mi(m);
for(mi.First();mi.NotDone();mi.Next())
mi.CurrentItem()=rand();
Однако класс такого итератора должен иметь доступ к представлению контейнера, поэтому его нужно сделать другом контейнера:
class Matrix{
...
friend class m_iter;
};