Программирование на основе классов и шаблонов

Перегрузка операций и отношения между классами

Аладин Дмитрий Владимирович

iu5edu.ru/wiki/cpp2

План

  1. Перегрузка функций и способы её осуществления.
  2. Перегрузка методом класса унарных и бинарных операций.
  3. Перегрузка внешней функцией унарных и бинарных операций.
  4. Перегрузка операции преобразования типа.
  5. Перегрузка операторов new и delete.
  6. Отношения агрегация и наследование между классами.

Про накопление жизненного опыта и проект с практики

center

Добро пожаловать в чудесный мир...

А как стать крутым?

center

Что вас ждёт в будущем?

  1. Архитектура автоматизированных систем обработки информации и управления
  2. Программирование на основе классов и шаблонов
  3. Электротехника
  4. Модели данных
  5. Базы данных
  6. Системное программирование
  7. Схемотехника дискретных устройств
  8. Электроника
  9. Вычислительные средства АСОИУ
  10. Операционные системы

Что вас ждёт в будущем? (продолжение)

  1. Сети и телекоммуникации
  2. Описание процессов жизненного цикла АСОИУ
  3. Сетевые технологии в АСОИУ
  4. Технология мультимедиа
  5. Технологии машинного обучения
  6. Сетевое программное обеспечение
  7. Методы поддержки принятия решений
  8. Эксплуатация АСОИУ
  9. Элементы управления в АСОИУ
  10. Парадигмы и конструкции языков программирования
  11. Автоматизация разработки и эксплуатации ПО

Кто ты, воин?

Будущий:

  • Архитектор автоматизированных систем управления и обработки данных
  • Инженер, способный отвечать на современные технологические вызовы

Напутствие 1

center

Учите математику и постарайтесь не забыть её к концу бакалавриата.
P.S. Понятно, что сложно, но все же =)

Напутствие 2

Используйте анализ происходящего вокруг (рефлексию) и прошлого опыта по отношению к текущим результатам (ретроспективный анализ).

Напутствие 3

Не забивайте на другие сферы!

center

Подробнее про Т-образную концепцию в разработке здесь.

В идеале бы вообще стать "Ш-специалистом"...

center

Вернёмся к теме

Следующий материал не украл, а адаптировал 😎

Основано на материале от @ashtanyuk. Адаптированный материал публикуется с сохранением условий распространения.

Да кто такой этот ваш ПЕРЕГРУЗКА?

center

Перегрузка функций

Перегрузка - это создание функции с тем же именем, но с отличающимся списком параметров. Иными словами, перегруженные функции называются одинаково, но отличаются количеством или типами своих параметров. Благодаря этому, компилятор их отличает друг от друга в точке вызова.

Перегрузка операций позволяет разработать аналог операции для пользовательского типа в виде функции с определенным набором параметров.

P.S. Про пользовательские типы начали говорить только в этой дисциплине, поэтому раньше перегрузка нас не интересовала.

Способы осуществить перегрузку

Перегрузка может быть осуществлена

  • в виде функции-члена класса (методом)
  • в виде глобальной функции (обычной или дружественной классу)

Пример использования перегрузки при сложении матриц:

Matrix a, b, c;
...
c = a + b;

Правила перегрузки

Для успешного использования перегрузки, надо знать и соблюдать ряд правил:

  • перегружать можно все имеющиеся операции, кроме
    • ?: - тернарный оператор;
    • :: - доступ к вложенным именам;
    • . - доступ к полям;
    • .* - доступ к полям по указателю;
    • sizeof, typeid и операторы cast.

Подробнее здесь.

Правила перегрузки (продолжение)

  • нельзя придумывать свои операции, например @, +++, )(, (_!_) и др.
  • при перегрузке нельзя изменять арность операции, ассоциативность, приоритет
  • способы перегрузки унарных и бинарных операций отличаются
  • хотя бы один из аргументов перегружаемых оператором должен быть пользовательского типа

Щепотка определений

Арность предиката, операции или функции в математике — количество их аргументов или операндов в возможности осуществлять последовательное применение формулы

Ассоциативность (сочетательность) — свойство бинарной операции , заключающееся в возможности осуществлять последовательное применение формулы в произвольном порядке к элементам , и .

Правила перегрузки (продолжение)

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

    • = - присваивание;
    • -> - доступ к полям по указателю;
    • () - вызов функции;
    • [] - доступ по индексу;
    • ->* - доступ к указателю-на-поле по указателю;
    • операторы конверсии и управления памятью.
  • следующие операторы можно перегрузить только в виде внешних функций (перегружаем методы класса <iostream>):

    • чтение из потока;
    • запись в поток.

Правила перегрузки (продолжение)

center

Общие правила перегрузки операторов можно подсматривать на learn.microsoft.com.

Перегрузка методом класса: бинарные операции

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

class Complex {
 private:
  double _re, _im;

 public:
  Complex(double re, double im) : _re(re), _im(im) {}
  Complex operator+(const Complex& c) { return Complex(_re + c._re, _im + c._im); }
};

В результате операции сложения возникает новый объект типа Complex.

Перегрузка методом класса: бинарные операции (продолжение)

Перегрузим присваивание:

class Complex {
  ...
  Complex& operator=(const Complex& c) {
    _re = c._re;
    _im = c._im;
    return *this;
  }
  ...
};

Теперь можно воспользоваться двумя перегруженными операциями:

Complex a(1.1, 2.2), b(3.3, 4.4), c(0.0, 0.0);
c = a + b;

Перегрузка методом класса: бинарные операции (продолжение)

...
Complex operator+(const Complex& c) { return Complex(_re + c._re, _im + c._im); }
...

Обратите внимание на то, как создается временный объект внутри метода operator+:

return Complex(_re + c._re, _im + c._im);

Сравните с другим возможным примером:

Complex temp(_re + c._re, Im + c._im);
return temp;

В результате выполнения обоих примеров, будет проведена оптимизация и создаваться лишний объект при копировании будет только один (с суммой).

Пример программы с перегрузкой бинарных операций

class Complex {
 private:
  int _re, _im;

 public:
  Complex(int re = 0, int im = 0) : _re(re), _im(im) { std::cout << "C(" << re << "," << im << ")" << std::endl; }
  Complex(const Complex& c) : _re(c._re), _im(c._im) { std::cout << "CC(" << _re << "," << _im << ")" << std::endl; }
  ~Complex() { std::cout << "D(" << _re << "," << _im << ")" << std::endl; }
  Complex& operator=(const Complex& c) {
    _re = c._re; _im = c._im;
    return *this;
  }
  Complex operator+(const Complex& c) {
    Complex temp(_re + c._re, _im + c._im);
    return temp;
  }
};

int main() {
  Complex a(1, 2), b;
  b = a + a;
}

Результат выполнения программы

C(1,2)
C(0,0)
C(2,4)
D(2,4)
D(2,4)
D(1,2)

А как это получилось???

Отладка программы из примера (шаг 1)

center

Отладка программы из примера (шаг 2)

center

Отладка программы из примера (шаг 3)

center

Отладка программы из примера (шаг 4)

center

Отладка программы из примера (шаг 5)

center

Отладка программы из примера (шаг 6)

center

А компилятор-то умным оказался!

Подробнее про управление памятью:

  1. Основы программирования: C++. Кувшинов Д.Р. Память
  2. C++ Programming/Memory Management

Перегрузка методом класса: унарные операции

Поскольку в операции участвует только один операнд, то никаких внешних ссылок методу, реализующему операцию, передавать не нужно.

Complex operator-() {
  Complex temp;
  temp.Re = -Re;
  temp.Im = -Im;
  return temp;
}

Для операций ++ и -- также существуют две формы: префиксная и постфиксная, которые реализуются при перегрузке особым образом.

Пример программы с перегрузкой унарных операций

class Coord {
  int _x, _y, _z;

 public:
  Coord(int x, int y, int z) : _x(x), _y(y), _z(z){}
  ...
  // Перегрузка префиксной формы:
  Coord& operator++() {
    ++_x; ++_y; ++_z;
    return *this;
  }
  // Перегрузка постфиксной формы:
  Coord operator++(int) {
    Coord temp = *this;
    ++_x; ++_y; ++_z;
    return temp;
  }
  ...
};

Перегрузка внешней функцией

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

center

Пример дружественной функции

class Coord {
  int _x, _y, _z;

 public:
  Coord() {}
  Coord(int x, int y, int z) : _x(x), _y(y), _z(z) {}
  friend Coord operator+(const Coord&, const Coord&);
};

Coord operator+(const Coord& c1, const Coord& c2) {
  Coord temp;
  temp._x = c1._x + c2._x;
  temp._y = c1._y + c2._y;
  temp._z = c1._z + c2._z;
  return temp;
}

int main() {
  Coord a(1, 2, 3), b(4, 5, 6), c;
  c = a + b;
}

Перегрузка присваивания

Операция присваивания (aka копирующая инициализация) определена в любом классе по умолчанию как поэлементное копирование. Если класс содержит поля, память под которые выделяется динамически, необходимо определить собственную операцию присваивания. Чтобы сохранить семантику присваивания, функция должна возвращать ссылку на объект, для которого она вызвана и принимать в качестве параметра ссылку на присваиваемый объект.

  • Оператор = можно перегружать только методом класса.
  • Оператор = при наследовании не наследуется. Если подумать, то будет видно, что при наследовании могут быть добавлены новые поля или изменено их смысловое значение и следовательно оператор = может не работать корректно.

Перегрузка потоков ввода/вывода

Функции operator<< и operator>> должны быть друзьями класса, описывающего пользовательский тип.

Функции принимают в качестве аргументов ссылку на входной (выходной) поток и ссылку на объект класса, данные из которого необходимо обработать. В качестве возвращаемых значений необходимо также указывать ссылки на поток ввода или вывода (в этом случае можно конструировать сложные потоковые конструкции).

P.S. Будьте аккуратны при использовании using namespace std. Неосторожное использование может привести к веселому поиску ошибок компиляции при попытке реализовать перегрузку потоков ввода/вывода для вашего пользовательского типа.

Пример перегрузки потоков ввода/вывода

class Complex {
  ...
  friend ostream& operator<<(ostream& os, Complex& c);
  friend istream& operator>>(istream& is, Complex& c);
  ...
};
ostream& operator<<(ostream& os, Complex& c) {
  return os << '(' << c._re << ',' << c._im << ')';
}
istream& operator>>(istream& is, Complex& c) { return is >> c._re >> c._im; }
int main() {
  Complex c(0, 0);
  std::cin >> c;  // 23,45
  std::cout << c; // (23,45)
}

Перегрузка операции преобразования типа

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

Также можно указать спецификатор explicit, который позволит преобразовывать типы только, если программист явно это указал (например static_cast<Point3>(Point(2,3));).

Пример:

Point::operator bool() const {
  return this->x != 0 || this->y != 0;
}

Пример программы с перегрузкой операции преобразования типа

class A {
  int x;

 public:
  A(int _x) : x(_x) {}

  operator int() const { return x; }
};

void foo() {
  A a;
  int b;
  b = a;
}

Перегрузка new и delete

Существует возможность перегрузить операторы new, delete для более эффективного распределения динамической памяти. Всего существует 4 формы операторов выделения и освобождения памяти

  1. new - работа с одиночными объектами
  2. new[] - работа с массивами объектов
  3. delete - работа с одиночными объектами
  4. delete[] работа с массивами объектов

Перегрузка new и delete (продолжение)

При перегрузке операторов для работы с динамической памятью нужно соблюдать следующие правила:

  • операторам не требуется передавать параметр типа класс;
  • первым параметром для new должен передаваться размер объекта size_t;
  • возвращаемые значения new должны иметь тип void*;
  • возвращаемые значения delete, delete[] должны иметь тип void;
  • первый аргумент delete, delete[] должен иметь тип void*;
  • данные операции являются статическими членами класса.

Работа перегрузки new и delete

class Foo {
  ... 
  void* operator new(size_t size);
  void operator delete(void* obj);

При вызове оператора new сначала выделяется память для объекта:

  • Если выделение прошло успешно, то вызывается конструктор.
  • Если конструктор выбрасывает исключение, то выделенная память освобождается.

При вызове оператора delete все происходит в обратном порядке: сначала вызывается деструктор, потом освобождается память. Деструктор не должен выбрасывать исключений.

Работа перегрузки new[] и delete[]

Когда оператор new[] используется для создания массива объектов, то сначала выделяется память для всего массива:

  • Если выделение прошло успешно, то вызывается конструктор по умолчанию (или другой конструктор, если есть инициализатор) для каждого элемента массива начиная с нулевого.
  • Если какой-нибудь конструктор выбрасывает исключение, то для всех созданных элементов массива вызывается деструктор в порядке, обратном вызову конструктора, затем выделенная память освобождается.

Для удаления массива надо вызвать оператор delete[], при этом для всех элементов массива вызывается деструктор в порядке, обратном вызову конструктора, затем выделенная память освобождается.

Особенности работы с delete

Стандартные функции выделения памяти при невозможности удовлетворить запрос выбрасывают исключение типа std::bad_alloc

Любую форму оператора delete безопасно применять к нулевому указателю.

center

Пример программы с перегрузкой new и delete

class X {
  ...
 public:
  void* operator new(std::size_t size) {
    std::cout << "X new\n";
    return ::operator new(size);
  }

  void operator delete(void* ptr) {
    std::cout << "X delete\n";
    ::operator delete(ptr);
  }

  void* operator new[](std::size_t size) {
    std::cout << "X new[]\n";
    return ::operator new[](size);
  }

  void operator delete[](void* ptr) {
    std::cout << "X delete[]\n";
    ::operator delete[](ptr);
  }
};

Перегрузка new и delete при наследовании

В классе (особенно, когда используется наследование) иногда удобно применить альтернативную форму функции освобождения памяти:

void operator delete(void* p, std::size_t size);
void operator delete[](void* p, std::size_t size);

Параметр size задает размер элемента (даже в варианте для массива). Такая форма позволяет использовать разные функции для выделения и освобождения памяти в зависимости от конкретного производного класса.

Ешё один пример программы с перегрузкой new и delete

void *X::operator new(size_t size) {
  void *p;
  std::cout << "In overloaded new.";
  p = malloc(size);
  if (!p) {
    throw std::bad_alloc;  // Throw directly than with named temp variable
  }
  return p;
}

void X::operator delete(void *p) {
  std::cout << "In overloaded delete.\n";
  free(p);
}

void *X::operator new[](size_t size) {
  void *p;
  std::cout << "Using overload new[].\n";
  p = malloc(size);
  if (!p) {
    throw std::bad_alloc;
  }
  return p;
}

void X::operator delete[](void *p) {
  std::cout << "Free array using overloaded delete[]\n";
  free(p);
}

Было сложно?

center

Отношения между классами

В этом разделе мы обсудим два основных типа отношений между классами: агрегацию и наследование.

  • Агрегация (горизонтальное отношение) предполагает, что один объект входит в состав другого.
  • Наследование (вертикальное отношение) подразумевает, что один объект является разновидностью другого.

Описание классов для примера

  • В качестве первого класса рассмотрим Engine (двигатель). У двигателя есть определенная мощность, рабочее топливо и его расход (литры на 1 км).
  • Вторым классом выступает Tank - топливный бак, который характеризуется емкостью. мы можем проверят, пуст ли бак, а также уменьшать количество топлива в нем на определенную величину.

Класс для примера Engine

#include <iostream>
using namespace std;

typedef unsigned short power_t;
enum Fuel { Petrol, Diesel };

class Engine {
 private:
  power_t _power;
  Fuel _fuel;
  double _consume;  // расход топлива
 public:
  Engine(power_t p, Fuel f, double c) {
    _power = p;
    _fuel = f;
    _consume = c;
  }
  double conFuel(size_t path) { return path * _consume; }
};

Класс для примера Tank

class Tank {
 private:
  double _capacity;

 public:
  Tank(double cap) : _capacity(cap) {}
  bool isEmpty() const { return _capacity <= 0.0; }
  void consume(double value) {
    if (!isEmpty()) _capacity -= value;
  }
};

Пример агрегации

При наличии двух узлов автомобиля попробуем создать класс Auto:

class Auto {
 protected:
  Engine *_engine;
  Tank *_tank;

 public:
  Auto(Engine *e, Tank *t) : _engine(e), _tank(t) {}
  void move(size_t path) {
    size_t current = 0;
    while (current < path && _tank->isEmpty() == false) {
      _tank->consume(_engine->conFuel(1.0));
      cout << "Current dist: " << current << "km" << endl;
      current++;
    }
    cout << "Stop!" << endl;
  }
};

Описание класса Auto

Автомобиль содержит двигатель и бак и умеет передвигаться на некоторое расстояние, расходуя при этом горючее. При опустошении бака автомобиль останавливается.

Класс Auto выступает в качестве базового класса для нескольких типов автомобилей.

center

Пример наследования

Рассмотрим легковой автомобиль (Car):

class Car : public Auto {
 protected:
  size_t _passengers;

 public:
  Car(Engine *e, Tank *t, size_t pass) : Auto(e, t) { _passengers = pass; }
};

Главная особенность нового класса: перевозка пассажиров.

Пример наследования (продолжение)

Аналогично можно создать грузовик, перевозящий грузы:

class Lorry : public Auto {
 protected:
  size_t _cargo;

 public:
  Lorry(Engine *e, Tank *t, size_t c) : Auto(e, t) { _cargo = c; }
};

В производных классах Carr и Lorry мы не определяем заново двигатель и бак - они переходят при наследовании от родительского класса Auto.

Конструктор производного класса должен вызвать конструктор базового и передать ему необходимые параметры.

В следующей серии

Про наследование и не только!

center

Вопросы?

center

If not, just clap your hands!