Конспект лекций по курсу ЯП

Лекция 1

История C++

Основные вехи развития:
  1. 1972 — создание языка С (Брайан Керниган, Деннис Ритчи)
  2. 1980 — создание языка С++ (Бьярн Страуструп)
  3. 2003 — принят первый стандарт языка C++
  4. 2011 — принят второй стандарт языка C++
Философия С++

Простейшая программа

Каждая программа на C++ состоит из одной или более функций. Среди них обязательно должна присутствовать главная функция main, которую и вызывает операционная система при запуске программы. Простейший пример:

int main() {
    return 0;
}

Значение, возвращаемое main, используется ОС для определения успешности завершения программы. Если не ноль, то в программе произошла ошибка.

#include <iostream>   // Заголовочный файл

int main() {
    std::cout << "Hello world!" << std::endl;
}

Пространства имен

Чтобы каждый раз не писать префикс std::, можно включить пространство имён в глобальную область видимости:

using namespace std; // Всё содержимое пространства std 
                     // становится видимым во всем файле.

using std::cout;     // Либо только элемент cout становится
                     // видимым во всем файле.

int main() {
    cout << "Hello world!";
}

Типы данных

С++ Pascal
int integer
bool boolean
double, float real
char char

Замечание: в типе char не поддерживается Unicode по умолчанию.

Способы инициализации переменных

double d = 3.14;    // старый стиль

double d {3.14};    // новый стиль
double d = {3.14};  // или так

Неявное преобразование типов

В C++ при попытке присвоить целочисленной переменной значение с плавающей точкой, произойдёт отбрасывание дробной части. При инициализации в новом стиле будет ошибка компиляции.

int i = 3.14;   // i == 3
int i {3.14};   // Ошибка компиляции

Тип char занимает 1 байт, поэтому диапазон его значений [-128,127], однако следующий код будет откомпилирован.

char c = 128;   // c == -128

Основные операции

C++ Pascal Примеры
= := int a = 4;
/ div 7 / 3 — тип int; 7 / 3.0double
% mod
a++ / ++a a = a + 1 ...
&& and i > 0 && i != 2
\ \
! not
== =
!= <>
<< shl (8 << 1 = 16)
>> shr
& and 7 & 3 == 3
\ or
^ xor
~ not

Возможна запись a = b = c, означающая b = c; a = c — множественное присваивание (справа налево).

Особое внимание стоит обратить на операцию сравнение на равенство:

if (a = 5)    // (1) Неверно! Но легко пропустить
if (5 = a)    // (2) Неверно! И компилятор выдаст ошибку

if (a == 5)  // (3) Верно! Но если спутать с (1), то легко не заметить
if (5 == a)  // (4) Верно! И если спутать с (2), то легко увидеть: ошибка компиляции

Условный оператор

if (условие) {
    ...
} else {
    ...
}

Допускается сокращённая форма (без else).

Тернарная условная операция

bool flag;
if (flag) {
    cout << "true";
} else {
    cout << "false";
}

Более короткая запись:

cout << (flag ? "true" : "false");

Оператор выбора

switch (i) {
    case 1:
        ...
        break;
    case 2:
    case 3:     
    ...
    default:
}

Операторы цикла

Цикл while

while (x > 10) {
    x -= 1;
}

Цикл do-while

do {
   x--;
} while (x > 0);

Похож на repeat/until в Паскале, но используется условие продолжения цикла (как в while), а не условие окончания как в repeat/until.

Цикл for

for (инициализация; условие_продолжения; действие_на_каждом_шаге) {
    ...
}

for (int i = 0; i < 10; i++) {
    cout << i << " ";
}                               // "0 1 2 3 4 5 6 7 8 9 "

for (;;);  // бесконечный цикл

Оператор , (запятая)

Возвращает значение своего последнего операнда, например:

int s = 0, i = 6;
cout <<  s = i*i, i;  // Выведет 6

Лекция 2

Функции

int abs(int x) {
    return x > 0 ? x : -x;  // После этого сразу выход из функции
}

// Функция, возвращающая void — процедура
void print(int i) {
    cout << "Значение = " << i;
}

Стандартные функции

Математические функции объявлены в заголовочном файле <cmath>.

#include <cmath>
abs(x);  floor(x);  sin(x);  pow(x, y);  sqrt(x);

inline-функции

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

inline int add(int a, int b) {
    return a + b;
}

int a = 3;
int c = add(a, 5);    // int c = a + 5;

Слово inline является лишь рекомендацией компилятору. На секунду вам может показаться, что вы умнее компилятора и сами знаете, какую функцию необходимо делать встраиваемой; в такой момент некоторые компиляторы могут даже поддаться вам, но помните: вы не умнее.

Ссылки

Ссылка — другое имя объекта («псевдоним»). Ссылка всегда инициализируется при объявлении.

int i = 5;
int &pi = i;   // ссылка на i

pi = 3;
cout << i;     // i == 3

Передача аргумента в функцию по ссылке

void calcSquareAndPerimeter(double a, double b, double &S, double &P) {
    S = a * b;
    P = 2 * (a + b);
}

int main() {
    double SS = 0.0, PP = 0.0;
    calcSquareAndPerimeter(3.0, 4.0, SS, PP);
    cout << "Perimeter: " << PP << ", Square: " << SS << "\n";
}

Структуры

struct Student {
    string name;
    int age;
};  // точка с запятой!

int main() {
    Student s {"Иванов", 19};               // Инициализация
    cout << s.name << ' ' << s.age << endl;

    Student s2 = s;                         // Независимая копия s
    s2.name = "Петров";
    cout << s.name;                         // "Иванов"
}

Передача структуры в функцию

Желательно передавать структуру в функцию по ссылке, иначе будет передана её полная копия; const запрещает изменение полей структуры.

void print(const Student &s) {
    cout << s.name << ' ' << s.age << endl;
}

Лекция 3

Многофайловая компоновка

Говорят, что переменная описана (определена), если она объявлена и под неё выделена память. Рассмотрим следующий пример, пусть в проекте есть два файла:

/* a.cpp */
extern int n;    // "Где-то дальше определено"
void f(int);     // Предварительное объявление

void main() {
    n = 5; f(n);
}


/* b.cpp */
#include <iostream>
int i;           // Описание переменной

// Определение функции
void f(int i) {
    std::cout << i;
}

В C++ имеет место независимая компиляция: все файлы проекта компилируются независимо один от другого. Компиляция состоит из этапа собственно компиляции и этапа линковки.

Описание процесса сборки программы

Заголовочные файлы

Содержат заголовки всех функций и объявления переменных, обычно имеют расширение *.h (header). Теперь можно вынести объявления функций из всех файлов в один (заголовочный):

/* myheader.h */
extern int n;
void f(int);

/* myheader.cpp */
#include <iostream>
int n;

void f(int i) {
    std::cout << i;
}


/* a.cpp */
#include "myheader.h"

void main() {
    n = 5; f(n);
}

/* b.cpp */
#include "myheader.h"

void makeZero() {
    n = 0;
}

Имена пользовательских заголовочных файлов в директиве include заключаются в двойные кавычки, а имена стандартных заголовочных файлов — в угловые скобки. Стандартные заголовочные файлы расположены в /INCLUDE. Поиск пользовательских файлов производится в текущем каталоге.

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

Содержимое заголовочных файлов

Что может содержать заголовочный файл:

Пример
Определения типов struct point { int x, y; };
Шаблоны типов template<class T> class V { /* ... */ }
Описания функций extern int strlen(const char*);
Определения встраиваемых функций inline char get() { return *p++; }
Описания данных extern int a;
Определения констант const float pi = 3.141593;
Перечисления enum bool { false, true };
Описания имен class Matrix;
Команды включения файлов #include <signal.h>
Макроопределения #define Case break;case
Комментарии /* проверка на конец файла */

В заголовочном файле никогда не должно быть:

Пример
Определений обычных функций char get() { return *p++; }
Определений данных int a;
Определений составных констант const tb[i] = { /* ... */ };

Глобальные описания в C++ и необходимость пространств имен

При простом определении глобальных сущностей они объединяются в глобальном пространстве имен. Для создания локального пространства имен используется ключевое слово namespace.

namespace MyNamespace{
    ... // содержимое пространства имён: типы, функции, что-угодно…
}

Пространства имен и заголовочные файлы

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

/* header.h */
namespace MyNamespace{
    extern int n;
    void f();
}

Основные этапы сборки проекта

  1. Препроцессирование.
  2. Компиляция каждого *.cpp-файла в объектный код (файлы *.obj или *.o).
  3. Линковка — сборка всех объектных файлов в один исполняемый (*.exe или ELF).

Ошибки во время линковки

Особенности линковки

Лекция 4

Препроцессор

Основные директивы препроцессора

Условная компиляция

#define FLAG1

#ifdef FLAG1
  // Код, помещённый здесь, откомпилируется только,
  // если определена макроподстановка FLAG1
#else
  // Данный код откомпилируется, если макроподстановка FLAG1 не определена
#endif /* FLAG1 */ 

Стражи включения (include guards)

Include guards — шаблонная конструкция (клише), вставляемая в заголовочный файл.

#ifndef H_FILE_NAME_HPP
#define H_FILE_NAME_HPP

// Содержимое заголовочного файла

#enfif /* H_FILE_NAME_HPP */

Такая кострукция защищает проект от повторного включения прототипов функции и зацикливания на этапе препроцессирования, что, в свою очередь, приводит к сокращению времени компиляции, а в случае зацикливания к корректной сборке проекта.

Precompiled headers

В режиме включенных предкомпилированных заголовков при компиляции заголовочных файлов все они попадают в откомпилированном виде в файл *.pch (для Visual Studio), тогда при необходимости повторного использования они не компилируются заново, а берутся из *.pch. Это приводит к уменьшению времени компиляции в больших проектах.

Перечисления

Перечисления в C++98

Перечисления, согласно стандарту C++98, объявляются следующим образом: cpp enum DayOfWeek {MON, TUE, WED, THU, FRI, SAT, SUN};

Каждый элемент перечисления это целое число: MON = 0, TUE = 1, … При этом существует возможность явно задавать значения элементов enum. Например:

enum DayOfWeek {MON, TUE = 3, WED, THU, FRI, SAT, SUN};

Тогда MON = 0, TUE = 3, WED = 4, …

Благодаря тому, что все имена перечисления являются целыми числами возможно следующее присваивание:

int day = MON;     // Ok, day == 0;

DayOfWeek dow = 5  // Ошибка компиляции
DayOfWeek wod = static_cast<DayOfWeek>(5);

Перечисления в C++11

При объявлении enum все имена перечисления экспортируются во внешнюю обасть видимости, это приводит к проблеме коллизии имен в крупных проектах. Для решения данной проблемы в C++11 был введен enum class — строго типизированные перечисления с ограниченной областью видимости. Объявление enum class происходит следующим образом:

enum class DayOfWeek {MON, TUE, WED, THU, FRI, SAT = 6, SUN};

При этом особенности работы с ним следующие:

// Ошибка при преобразовании DayOfWeek к int
int day = DayOfWeek::WED;

// Ошибка: WED нет в облати видимости
int wday = WED;

// Присваение переменной значения из множества имен перечисления DayOfWeek
DayOfWeek dow = DayOfWeek::FRI;

// При необходимости присваивания переменной типа DayOfWeek целого числа
// можно воспользоваться оператором static_cast<type>(object);
DayOfWeek sat = static_cast<DayOfWeek>(6);

Использование перечислений

Часто имена перечислений используются в операторе switch.

switch (dayOfWeek) {
    case DayOfWeek::MON: cout << "Monday\n"; break;
    case DayOfWeek::TUE: cout << "Tuesday\n"; break;
    case DayOfWeek::WED: cout << "Wedesday\n"; break;
    case DayOfWeek::THU: cout << "Thusday\n"; break;
    case DayOfWeek::FRI: cout << "Friday\n"; break;
    case DayOfWeek::SAT: cout << "Satuday\n"; break;
    case DayOfWeek::SUN: cout << "Sunday\n"; break;
}

Массивы

Особенности массивов в С/C++:

Работа с массивами C/C++:

// Создание массива из 5 элементов типа int
int b[5]; 

// Создание и инициализация массива из 4 элементов 
int a[] = {2, 3, 5, 7};

// Запись в последний элемент масива
a[3] = 1;

// Так нельзя копировать, только в цикле
b = a;  

Так как массивы в C/C++ не помнят своего размера, то его необходимо определять вручную след образом:

int size = sizeof(arr2) / sizeof(int);
    // sizeof(arr2) возвращает размер массива в байтах
    // sizeof(int) возвращает размер элемента массива

Цикл по массиву (foreach)

int a[] = {3, 7, 9, 5, 7, 2, 7};

for (int x : a)     // Доступ к x только на чтение
    cout << x;  

for (int &x : a)    // x передается по ссылке, поэтому
    x += 1;         // доступ есть на чтение и запись

Передача массива в функцию

В C/C++ массив всегда передается по ссылке, поэтому использование оператора sizeof(), для определения его размера, бесполезно. Потому при передаче массива в функцию следует явно передавать и его размер.

const int n = 5;
int a[n] = {1, 3, 5, 6, 1};  // создали массив фиксированной длины 5

// …

void f(int a[], int len) {   // создали функцию для работы с массивами
  // …                       // любой заданной длины len
}

// …

f(a, n);                     // вызвали функцию для нашего массива длины 5

Лекция 5

Строки в стиле C

В языке С строки определяются как одномерный массив типа char.

char str[] = "Hello world!!!";

Это строка из 14 символов, однако размер массива будет равен 15, так как строки в C заканчиваются символом \0 — нуль терминатор (нулевой байт: байт, все биты которого равны 0). Иногда такие строки называют нуль-терминированными.

Хранение строки str[] в памяти

Недостаток такого подхода состоит в том, что для того чтобы узнать длину строки необходимо просканировать всю строку до конца, в поисках \0 — это может занять много времени.

При попытке стандартным образом ввести с консоли значение "Hello world!" в str, будет введено только "Hello" (до первого пробела). Чтобы этого не происходило можно использовать следующую запись:

std::cin.getline(str, 14);  // 14 — количество вводимых символов

Строки в стиле C++

В 1980 году появился класс string:

string s1 = "Hello ";
string s2 = "world !!!";

s1.size();  // s1 - экземпляр класса, он помнит свою длину
s1[0];      // Индексация символов с нуля
s1 = s2;    // Строки можно присваивать друг другу
s1 += s2;   // Строки можно прибавлять друг другу 
s1 == s2;
s1 < s2;    // Строки можно сравнивать

Несмотря на то, что скорость работы C-строк немного выше чем у класса string, на прикладном уровне лучше использовать string. Однако системным программистам чаще приходится пользоваться char*.

Ввод-вывод

cout << s
cin >> s;

Если мы вводим "Hello world" в s будет храниться только "Hello", поэтому надо использовать getline(cin, s);.

Как передавать в функции C- и C++-строки

С-строки

void p(char s[])        // s передается по ссылке
void p(const char s[])  // const запрещает изменение s

С++-строки

void p1(string &s)         // s можно менять
void p1(const string &s)   // s нельзя менять

Двумерные массивы

Двумерные массивы определяются как массив массивов. Вот как будет выглядеть двумерный массив int a[3][4].

a[ ][0] a[ ][1] a[ ][2] a[ ][3]
a[0][ ] a[0][0] a[0][1] a[0][2] a[0][3]
a[1][ ] a[1][0] a[1][1] a[1][2] a[1][3]
a[2][ ] a[2][0] a[2][1] a[2][2] a[2][3]

Передача в функции двумерных массивов

void print(int a[3][4], int m, int n) {
    for (int i = 0; i < n; i++)       
        ...
}

При передаче массива в функцию сохранится размер массива a[][4]: размер внешнего массива потеряется, а размер внутренних массивов сохранится.

Определение типов в C++

Синонимы (псевдонимы) типов определяются с помощью typedef.

typedef unsigned char byte;

typedef int Arr[3];     // Arr имя типа
typedef int Matr[3][4]; // Matr имя типа

// Теперь можно использоваать
void print(Matr a, int m, int n);

По сути объявление переменной Matr a будет заменено на int a[3][4].

Указатели и адреса

C++ унаследовал от C возможность работы на низком уровне.

Пусть мы имеем ячейку памяти int i = 5. Объявление int *p; вводит указатель, то есть переменную, которая может хранить адрес ячейки памяти с любым int, например, i.

int i = 5;
int *p;
p = &i     // В указателе p хранится адрес ячейки памяти i

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

p = NULL;    // Так часто делали в C. Макрос NULL определён в <cstdlib>.
p = 0;       // Так советует Страуструп для C++98.
p = nullptr; // C++11

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

*p = 6 // Операция разыменования

Указатели и ссылки

//Указатели
int *p = &i;
*p = 6; 

// Ссылки
int &r = i;   // i и r — одна ячейка памяти
r = 6;

Если & пишется после названия типа, то это ссылка. В противном случае, если он пишется перед именем переменной, то это адрес этой переменной. Ссылку можно трактовать, как указатель, который постоянно находится в разыменованном состоянии.

Передача параметров в функции

По ссылке:

void q(int &r) {
    r++;
}

int i = 5;
q(i);      // i == 6

По указателю:

void q(int *p) {
    (*p)++;   // Скобочки важны!!!
} 

int i = 5;
q(&i);     // i == 6

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

Указатель void*

void *p;    // указатель на область памяти

int i = 5;
p = &i;

double d = 3.14;
p = &d;

т.е. p может хранить адрес любого объекта.

void *p = &i

// Ошибка компиляции: нельзя разыменовать p
*p = 6

// Явное приведение к типу int* в стиле C
(int*)p = 6;    

// Использование представляет опасность если i не является int.
// Стиль C++ более явно заявляет об этой опасности:
*static_cast<int*>(p) = 6;       // но работает точно так же, как и выше

p = &d;
*static_cast<double*>(p) = 2.8;

Невозможно выполнить:

int *pa;
double *pb;
pa = pb;
pb = pa;

Но можно с помощью явного приведения типов (в стиле C или в стиле C++, но не static_cast, а reinterpret_cast).

Указатели на структуры

struct Person {
     string name;
    int age;
};

Person p {"Иванов", 19};
Person *pp = &p;
(*pp).age = 20;
pp -> age = 20;   // Операция доступа к полю в памяти

Указатели и константность

int i = 5;
int *p = &i;
const int *cp = &i; // Указатель на константу
cout << *cp;
(*cp)++;              // Ошибка компиляции

Это используется при передаче аргументов в функции. Объявление const int *p заведомо не позволяет написать функцию, которая изменяет переменную, переданную через указатель.

void q(const int *p) {
  (*p)++;      // Ошибка компиляции
  cout << *p;
}

Константные указатели

int = 5;
const int n = 10;      // Обычная константа
int* const pc = &i;    // Константный указатель
pc = &j;               // Ошибка компиляции
(*pc)++;               // А здесь ошибки не будет

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

const int n = 10;
int* pn = &n;    // Ошибка компиляции

const int *pn = &n;
*pn = 11;        // Ошибка компиляции
cout << *pn;

Однако возможно заставить компилятор снять константность:

*const_cast<int*>(pn) = 11;

Константный указатель на константу

const int* const pn = &n;

Лекция 6

Ссылки на константы

    int i = 5;
    int& ci = i;
    const int& cci = i; // Здесь все будет нормально
    const int n = 10;
    int& cn = n; // Такое компилятор запретит
    int& cn = const_cast<int&>(n);
    const int& ccn = n; 

Указатели и массивы

В C++ указатели и массивы тесно связаны

int a[10];

int* p = &a[0]; // адрес первого элемента
*p = 5;
a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] a[9]
5
*p

Операции при работе с указателями

В Pascal таких операций нет т.к он является более высокоуровневым языком.

Выполним следующую операцию p++;. Теперь указатель p указывает на следующий(второй) элемент массива.

a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] a[9]
5
*p

Операция p++ увеличивает адрес в зависимости от типа указателя т.е. p++ ~ p += sizeof(int)

p += 1;     // Переход на следующий элемент массива
p += n;     // Увеличение на n элементов массива
p+1     // Адрес следующего элемента  
p+n = p1;   // Записать в `p1` адрес n-го элемента
p1 - p = n; // Количество между указателями

А вот складывать указатели нельзя!!!

*(p+0) и a[0] - являются ссылками на первый элемент массива
*(p+1) и a[1] - являются ссылками на второй элемент массива
*(p+2) и a[2] - являются ссылками на третий элемент массива

т.е a[n] == *(p+n) - связь массивов и указателей

Имя массива a может быть неявно преобразовано к указателю на свой первый элемент. Т.е на самом деле а это указатель на первый элемент массива. Значит мы можем написать проще:

int* p = a;

a является константным указателем на свой первый элемент т.е он как-бы описан таким образом int* const a;

Отсюда становится понятно, почему нельзя писать a = a1. Т.к имя массива константный указатель , то нельзя присваивать один массив другому.

Вообще говоря, более строгая связь массивов и указателей выглядит следующим образом: a[n] == *(a+n)

Отсюда следует вывод(крамольная истина): В языке C массивов нет - есть только указатели!!!

Следствие 1. Понятно, почему нет контроля выхода за границы массива.

Следствие 2. Понятно почему массивы индексируются с нуля. Это самое эффективное по этой формуле.

Следствие 3. a[n] == *(a+n) == *(n+a) == n[a]

Идиома *p++

Идиома - устойчивое выражение, которое воспринимается как единое целое.

Теперь допустим, нам необходимо сделать следующее:

int a[10];
int* p = a;

*p = 3;
p++;

Для краткости это хочется заменить на *(p++) = 3; А что, если записать *p++? Это можно воспринимать как *(p++) или как *(p)++. В C/C++ унарные операции ассоциируются справа налево, поэтому в данном случае ++ относится к указателю, следовательно *p++ ~ *(p++).

Пример 1. Заполнить массив a нулями

int a[10];
//int* p = a; // Однако это можно перенести в раздел инициализации for(;;)

for(int* p = a; p != a+10; *p++ = 0;);

Передача массива в функцию с помощью указателя

void InitZero(int* a, int n)
{
  for(int* p = a; p != a+n;) *p++ = 0;
}

// int* a ~ int a[] 

Пример 2. Даны 3 массива int a[10], b[10], c[10]. Необходимо заполнить массив c[10] суммой элементов массивов a[10] и b[10].

int *pa = a, *pb = b, *pc = c;

//for(; pa != a + 10;)
while(pa != a + 10)
  *pc++ = *pa++ + *pb++;

С-строки и указатели

Пример 1.

char s[10] = "Hello";
s[0] s[1] s[2] s[3] s[4] s[5] s[6] s[7] s[8] s[9]
H e l l o \0 \0 \0 \0 \0
char* pc = s;
//while(*pc != '\0') // Но можно короче 
while(*pc)
  cout << *pc++ <<  ' ';

Пример 2. Копирование строк

char s[10] = "Hello";
char s1[10];

s1 = s; // Нельзя т.к. s1 объявлен как char* const s1

char* mysrtcpy(char* p, const char* q)
{
  while(*q)
    *p++ = *q++;
  *p = 0;
  return p-1;

  // Или можно сделать короче, но менее понятно

  while(*p++ = *q++);
  return p-1;
}

Лекция 7

Стандартные функции для работы с C-строками

#include <cstring>

// Возвращает длину строки p
size_t strlen(const char* p);

// Копирует строку q в строку p 
char* strcpy(char* destination, const char* source);
// Возвращает указатель на последний символ скопированной строки

// Сравнение строк в лексикографическом порядке
int strcmp(const char* s1, const char* s2);
// < 0, s1 < s2
// = 0, s1 == s2
// > 0, s1 > s2

// Добавляет source в конец destination
char* strcat (char* destination, const char* source);
// Считается, что в строке s1 достаточно памяти

// Ищет в строке s символ с
char* strchr(const char* s, char c);

// Ищет в строке s подстроку s1
char* strstr(const char* s, const char* s1);

Контроль памяти при работе с этими функциями

char* s1 = "Hello";
char s1[];       // Указатель объявляется как неизменяемый
char* s2;

strcpy(s2, s1);  // Не работает, потому что память под s2 не выделена.

char s2[4];
strcpy(s2, s1);

Контроль памяти лежит на программисте. В случае возникновения ошибки она, по-началу, может себя никак не проявить.

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

char* s1 = "Hello";
char s2[4];
strncpy(s2, s1, 4);  // Защищает от переполнения

Указатели и динамическая память

В C/С++ динамическая память управляется с помощью указателей.

Замечание. В С++ сборщика мусора нет! Ответственность за выделение и освобождение памяти лежит на программисте.

Если необходимо в динамической памяти выделить место для int, используется оператор new.

int* pi = new int;

*pi = 5;
(*pi)++; // *pi == 6

Управление динамической памятью с помощью указателей

Освобождение памяти производится с помощью оператора delete.

delete pi;
pi = nullptr;  // Хороший стиль

Освобождение динамической памяти

Присваивание, указателю на высвобожденную память, значения nullprt защищает от ошибки при повторном (ошибочном вызове) delete.

Ошибки при работе с динамической памятью

1. Попытка разыменования нулевого указателя
int *pi;
*pi = 5
2. Утечка памяти
int *pi = new int;
pi = new int;

// Особенно сильно это чувствуется при использовании в циклах
for(;;)
  pi = new int;

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

void f()
{
  int* pi = new int;
}
3. Обращение к освобожденной памяти
int *pi = new int;
*pi = 5
delete pi;
*pi = 6;

Массивы в динамической памяти(динамические массивы)

int* pi new int[10]; // Выделение памяти для 10 элементов

pi[0] = 5;
pi[1] = 3;
...

Для возврата этой памяти используется оператор delete[]:

delete[] pi;

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

Как передавать динамические массивы в функции

void print(const int* pi, int size)
{
  for(int i = 0; i < n, i++)
    cout << pi[i] << ' ';
}

...

int* pi = new int[10];
print(pi, 10);

Двумерные динамические массивы

Выделение памяти для статических массивов

int a [3][4]; // размеры это константы, поскольку память должна выделяться
              // на этапе компиляции

void f(int a[][4], int m, int n)
{
  ...
}

Вообще говоря. так писать плохо. В таких случаях лучше использовать двумерные динамические массивы.

Выделение памяти для динамических массивов

int** a; // a - указатель на начало массива из int*

int m, n;
cin >> m >> n; // Здесь размеры определяются на этапе выполнения программы
a = new int*[m];

Выделение памяти для динамического массива - этап 1

Каждый элемент имеет тип int* и значение NULL.

for(int i = 0; i < m; i++)
  a[i] = new int[n];

// Теперь можно обращаться
a[1][2] ~ *(*(a+1)+2)

Выделение памяти для динамического массива - этап 2

Передача двумерного динамического массива в функции

void print(const int** a, int m, int n)
{
  for(int i = 0; i < m; i++)
  {
    for(int j = 0; i < n; j++)
      cout << a[i][j] << ' ';
    cout << endl;
  }
}

Двумерный статический массив в эту функцию передать нельзя.

Замечание. Динамические массивы позволяют задавать свой размер во время выполнения программы.

Возврат памяти

for(int i = 0; i < m; i++)
  delete[] a[i];
delete[] a;

Динамические структуры данных

Линейный односвязный список

struct node
{
  int dat;
  node* next;
  node(int data, node* next)
  {
    // this указатель на себя
    this->data=data;
    this->node=next;
  }
};

В С++ объект вызова конструктора не содержится в динамической памяти.

node n1(4, nullptr);

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

node n2(3, &n1);
node n3(5, &n2);

node* p = &n3;

Шаблоны структур

Шаблоны впервые появились в C++, затем они перекочевали в других языках программирования

template<typename T>
struct node
{
  T dat;
  node<T>* next;
  node(T data, node<T>* next)
  {
    // this указатель на себя
    this->data=data;
    this->node=next;
  }
};

Теперь предыдущий пример будет выглядеть следующим образом:

node<int> n1(4, nullptr);
node<int> n2(3, &n1);
node<int> n3(5, &n2);

node<int>* p = &n3;

Лекция 8

Линейный односвязный список в динамической памяти

Воспользуемся для создания линейного списка шаблон структурой node из 7 лекции:

template<typename T>
struct node
{
    T data;
    node<T>* next;

    node(T data, node<T>* next)
    {
        // this указатель на себя
        this->data = data;
        this->next = next;
    }
};

При объявлении нового экземпляра структуры node, как это описано в предыдущей лекции, этот объект создается в статической памяти. То есть объект n1 будет храниться на стеке:

node<int> n1(5, nullptr);

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

Как создать node в динамической памяти

node<int>* pn = new node<int>(5, nullptr);

В C++ динамическую память выделяет не конструктор, а оператор new. Конструктор только создает объект в выделенной памяти.

В отличии от .NET в C++ нет сборщика мусора, и ответственным за удаление объекта из динамической памяти, является программист.

В C++ размерная модель объектов, а ссылочную можно моделировать с помощью указателей.

Добавление элемента в начало односвязного списка

node<int>* pn = nullptr;
pn = new node<int>(5, pn);

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

tempate <typename T>
void add_first(node<T>* &pn, T x)
{
    pn = new node<T>(x, pn);
}

Запись node<T>* &pn означает, что pn это ссылка на указатель типа node<T>, и изменения происходящие с ней внутри фунцкии повлияют и на изменение фактического параметра.

node<int>* pn = nullptr;

add_first(pn, 5);
add_first(pn, 3);
...

Создание односвязного списка

Надо обратить внимание на то, что в отличии от шаблона структуры, в шаблоне функции указывать тип не надо, он автоматически выводится по типам фактических параметров.

Где хранить шаблоны функций, структур и классов.

В результате компиляции шаблона генерируется 0 байт, поскольку конкретный тип не указан. Если описать шаблон функции в одном *.cpp файле, то при многофайловой компоновке программы эта функция не будет доступна в другом файле.

Решение. Все шаблоны функций, классов и структур должны быть помещены в заголовочные файлы.

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

В C++ шаблоны компилируются в два этапа:

  1. Компиляция собствено шаблона
  2. Компиляция шаблона инстанцированного конкретным типом.

И на каждом этапе могут возникнуть ошибки.

Отличие шаблонов C++ от обобщений .NET

template<typename T>
T inc(T t)
{
    return t + 1;
}
Student s(...);
inc(s);  // В C++ произойдет ошибка на этапе компиляции
         // А в динамических языках это ошибка времени исполнения

Цикл по односвязному списку

template <typename T>
void print(node<T>* p)
{
    while(p)
    {
        cout << p -> data << ' ';
        p = p -> next;
    }
}

Указатели на функции

В PascalABC.NET работа с указателями на функции осуществляется следующим образом:

type BitOp = function (a, b: real): real;

var op: BinOp;
write(op(3, 5));
op := mult;
write(op(3, 5));

Аналогичный код на C++ выглядит так:

// Указатель на функцию с таким прототипом
typedef double (*BinOp) (double, double);

BinOp bop = &add;
(*bop)(3, 5);


// Или как и описание переменной
double (*op)(double, double)

op = mult;
op(3, 5);    // Так тоже можно вызывать template <typename T>

Действие передаваемое параметром (callback)

template <typename T>
// action — переменная типа "указатель на функцию"
void for_each(node<T>* p, void (*action)(T&))
{
    while(p)
    {
        action(p -> data);
        p = p -> next;
    }
}

void print(int &x)
{
    cout << x << ' ';
}

void inc(int &x)
{
    x++;
}

for_each(pn, print);
for_each(pn, inc);
for_each(pn, print);

В языке C/C++ структурная эквивалентность типов, а не именная.

Лекция 9

Освобождение памяти занимаемой списком

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

template <typename T>
void delete_list(node<T>* p)
{
    while(p)
    {
        auto p1 = p;
        p = p -> next;
        delete p1;
    }
}

После завершения программы, вся выделенная динамическая память будет принудительно возвращена системе. Однако не стоит опираться на этот факт.

В сложных задачах, ручное освобождение памяти может представлять большую проблему. Например, в графах, где легко может утеряться указатель на другие узлы.

Векторы и строки (С++)

К моменту создания C++, внезапно оказалось, что C-строки, представимые в виде массива символов, ровно как и нерасширяемые массивы, морально устарели. Поэтому Бьярн Страуструп ввёл супер удобные классы string и vector<T>, которые несколько повышают уровень абстракции и работают столь же эффективно.

Работа со строками

Операции Описание
s[i] i-ый символ
s.size() Размер строки
s.c_str() Константный указатель на c-строку
string s1 = s Новая копия строки
s.erase(1, 3) Удаление 3 символов
s.substr(1, 3) Подстрока из 3 символов
s.insert(1, s1) Вставка s1 перед 1 символом
s.find_first_of(s1) Поиск любого символа из s1
s.find(s1) Поиск подстроки в строке
string::npos -1, ~ нет совпадений

string::npos — это специальная константа, которая была введена в язык для обозначения несуществующего индекса в массиве. Значение этой константы, строго говоря, зависит от реализации стандартной библиотеки, однако, как правило, ее значение равно -1.

Векторы

Вектор — это контейнер, представляющий из себя массив, размер которого может меняться во время исполнения программы. Он так же как и обычные массивы использует неприрывные "бруски" памяти для хранения элементов. Это значит, что доступ к элементам осуществляется быстро, используя смещения указателей.

#include <vector>
using namespace std;

vector<int> v(10);   // Вектор из 10 нулей
v[0] = 5;

v.size();
v.push_back(777);  // Вставить новый элемент в конец
p.pop_back();      // Удаление последнего элемента

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

Что происходит, когда векторы присваиваются

При выполнении операции v1 = v происходят следующие действия:

  1. Память занимаемая v1 уничтожается.
  2. Для v1 выделяется память размера v.
  3. Содержимое v копируется в v1.

Процесс копирования вектора

Как вектор управляет памятью

Вектор выделяет чуть больше памяти чем ему требуется для хранения элементов. Поэтому различают логическую длину (size) и фактическую длину (capacity).

v.capacity();  // Получить фактическую длину

Как только size пытается превысить capacity, capacity увеличивается и происходит выделение дополнительной памяти.

v.resize(n);    // Изменить размер (обрезать / дополнить нулями)
v.reserve(n);   // Увеличить вектор, чтобы можно было хранить n элементов

При добавлении элемента, если выполняется условие size > cap тогда capacity увеличивается в 2 раза: v.reserve(size * 2).

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

v.shrink_to_fit();      // C++11
vector<int>(v).swap(v); // C++98

Классы и перегрузка операций

В языке С++ есть встроенные типы. Например

int i;
double d;

При этом мы можем производить с ними привычные действия.

d = 5.0; i = 3;
d = i * d;

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

Отличие класса от структуры, лишь в том, что в классе поля по-умолчанию приватные, а в структуре публичные.

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

Рассмотрим процесс создания класса на примере класса Date. Описание самого класса необходимо помещать в *.h файл.

/* date.h */

class Date
{
    private: // это слово писать не обязательно
        int y, m, d;

    public:
        // Конструктор класса
        Date(int d, int m, int y)
        {
          this->d = d;
          this->m = m;
          this->y = y;
        }

        void add_days(int n);
};

Все функции размещенные внутри класса автоматически помечаются модификатором inline.

Определение внешних функций класса осуществляется в *.cpp файлах.

/* date.cpp */

#include "date.h"

// Определение некоторой функции вне интерфейса класса
void Date::add_days(int n)
{
  ...
}

При надлежащей реализации класса Data, в C++ будут возможный действия следующего рода:

Data d(17, 10, 14), d1 = d;
cin >> d1;
Date d2(31, 12, 14);

d += 7;
d1 = d1 - 7;
int n = d2 - d;
if (d == d1)...
d++; ++d; d1--; --d1;
d2++;
cout << d << ' ' << d1

Как видим это код выглядит так, будто Data это встроенный тип.

Перегрузка бинарной операции

Перегрузка операции это описание операции с тем же именем, но работающей с другими типами.

@ - обозначение бинарной операции в рамках курса

Существует 2 способа

1.Как функцию-член

a.operator@(b)

2.Как внешнюю функцию

~operator@(a, b)

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

class Date
{

    ...

    void operator+=(int n)
    {
        add_days(n); // inline
    }
}

т.е. d += 7; ~ d.operator+=(7); ~ d.add_days(7);

Передача объектов в функцию

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

void f(vector<int> &v);

void f(const string &s);   // Изменить строку не получится

Лекция 10

Конструктор со списком инициализации

В лекции 9 рассматривался следующий пример конструктора класса Date.

Date(int d, int m, int y)
{
    this->d = d;
    this->m = m;
    this->y = y;
}

Данная реализация является упрощённой («как в Паскале»). В C++ для инициализации полей в конструкторе используются списки инициализации.

class Date
{
    int d, m, y;

public:
    Date(int d, int m, int y) : d(d), m(m), y(y) {}
};

Здесь

d(d), m(m), y(y) — список инициализации.

Конструкция d(d) означает поле(аргумент конструктора).

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

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

Перегрузка операций

Как было сказано на прошлой лекции, бинарные операции можно перегружать двумя способами:

При перегрузке бинарной операции, очень важно правильно ответить на вопрос:

Как выбрать между функцией-членом и внешней функцией?

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

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

Рассмотрим перегрузку операции сравнения на равенство на примере класса Date из лекции 9.

class Date
{
    int d, m, y;

public:
    //bool operator==(Date d1, Date d2) типичная ошибка
    bool operator==(Date const & other)
    {
        return d == other.d &&
               m == other.m &&
               y == other.y;
    }
};

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

Аргумент оператора правильнее передавать по ссылке на константу. Это общее правило C++: сначала формальный параметр описывается как ссылка на константу, затем, при необходимости, из объявления параметра убирается const. В последнюю очередь стоит подумать о передаче объекта по значению (это нужно довольно редко).

Перегрузка операции вывода в поток (чтения из потока)

Должен ли operator<<(>>) быть функцией-членом? Ответ: нет, он должен быть внешней функцией т.к cout — объект существующего класса, в который мы не можем добавить перегрузку для вывода объекта нашего нового класса.

Если operator<<(>>) должен быть внешней функцией, тогда как получить доступ к полям класса Date? Для решения это проблемы operator<< обычно объявляется другом класса: в начале заголовка добавляется ключевое слово friend.

/* date.h */
class Date
{
    int d, m, y;
public:
    // …

    friend
    ostream & operator<<(ostream & os, Date const & d);
};


/* date.cpp */
ostream & operator<<(ostream & os, Date const & d)
{
    os << d.d << '.' << d.m << '.' << d.y << '\n';
    return os;
}

Функция operator<< должна возвращать ссылку на полученный объект потока для допустимости цепочек типа cout << d1 << d2;

Операция чтения из потока определяется аналогичным образом, только вместо ostream используется istream.

Более короткий вариант

Т.к. operator<< небольшой, то его лучше сделать inline. Перенесем реализацию в заголовочный файл.

/* date.h */
class Date {
    int d, m, y;
public:
    // …

    friend
    ostream & operator<<(ostream & os, Date const & d)
    {
        return os << d.d << '.' << d.m << '.' << d.y << '\n';
    }
};

Если друг класса определен прямо в классе, то он остается внешней функцией и становится inline.

Перегрузка арифметических операций

Канонический вид перегрузки арифметических операций

Пусть @ ∈ {+, -, *, /}. Обычно определяют пару функций: operator@=, operator@. В таком случае operator@= определяется как функция-член, а operator@ — как внешняя функция.

Операция @= называется присваивающей формой операции @.

Рассмотрим перегрузку арифметических операций на примере класса BigInteger.

class BigInteger {
    int data[1024];

public:
    BigIntiger & operator+=(BigInteger cons & other)
    {
        // Цикл по data: суммирование и перенос
        // Возвращаем ссылку на себя
        return *this;
    }
};

BigInteger operator+(BigInteger const & bi1, BigInteger const & bi2)
// функция не может возвращать ссылку, потому возвращает новый объект
{
    BigInteger res(bi1);  // копия bi1
    res += bi2;           // res хранит bi1 + bi2
    return res;
}

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

Поскольку @= является inline, дополнительных расходов на её вызов не возникает.

Лекция 11

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

Для класса Date очень полезными будут операции d++ и ++d. Рассмотрим реализацию таких операторов.

Префиксная унарная операция (@a ++a --a) (общий случай)

Префиксная унарная операция может быть определена двумя способами:

  1. В виде функции-члена: @a ~ a.operator@().
  2. В виде внешней функции: @a ~ operator@(a).

Определим эту операцию для класса Date

class Date
{
    
    public:
    
    // определение префиксного ++
    Date & operator++()
    {
        add_days(1);
        return *this;
    }
};

Постфиксная унарная операция (a@ a++ a--)

При перегрузке префиксной и постфиксной унарных операций встает вопрос, об интерпретации записи типа operator@. В первых версиях языка префиксная и постфиксная операции определялись одинаково, но 1998 году для их разделения был введен фиктивный параметр типа int.

Постфиксная унарная операция также может быть определена двумя способами:

  1. В виде функции-члена a@ ~ a.operator@(int).
  2. В виде внешней функции a@ ~ operator@(a, int).

Рассмотрим ее реализацию для класса Date:

class Date
{
    
    public:
    
    // определение постфиксного ++
    // d++ возвращает значение до увеличения, 
    // значит возвращать нужно копию, а не ссылку.
    Date operator++(int)
    {
        Date d = *this;
        add_days(1); // ~ ++(*this);
        return d;
    }
};

Как видим, из-за копирования всего объекта операция d++ будет выполняется дольше, поэтому надо выбирать в пользу ++d. Это верно для своих типов, но для встроенных типов компилятор производит оптимизацию. В результате записи i++ и ++i, например, для переменных типа int, равноценны.

Перегрузка операции !=

Операция != симметрична, поэтому предпочтительным выбором будет перегрузка оператора operator!= как внешней функции. Однако, для увеличения производительности, объявим функцию другом класса — таким образом она станет inline.

class Date
{
    
    public:
    
    friend bool operator!=(const Date &d1, const Date &d2)
    {
        return !(d1==d2);
    }  
};

Как видим, использование ранее определенной операции == позволяет не обращаться к внутренним полям класса напрямую. Так как описанная функция является inline, то в результате будет произведена следующая замена.

d1!=d2 ~ !(d1==d2) ~ !(d1.d == d2.d && d1.m == d2.m && d1.y == d2.y)

Класс динамического массива

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

template<typename T>
class myvector
{
    T* data;
    int sz;

  public:
    myvector(int size): sz(size)
    {
        data = new T[sz];
    }

    int size()
    {
        return sz;
    }
};

Константные функции-члены

Теперь напишем процедуру, которая выведет на экран содержимое нашего вектора.

void print(const myvector<int> &v)
{
    for(int i = 0 ; i < v.size(); i++)
    cout << v[i] << ' ';
}

Такой код не скомпилируется: ошибка произойдет из-за функции-члена v.size() так как эта функция, потенциально может изменить состояние объекта, в то время как формальный параметр v объявлен с модификатором const.

Для решения этой проблемы функцию size() надо определить как константный.

int size() const
{
    return sz;
}

Перегрузка операции []

Теперь рассмотрим перегрузку операции operator[] для того же класса myvector.

template <typename T>
class myvector
{
    T* data;
    int sz;

  public:
    
    T& operator[](int n)
    {
        if(n < 0 || n >= sz)
            throw n;
        return data[n];
    }
};

Так как operator[]inline, то будет произведена замена:

v[i] ~ v.operator[](i) ~ v.data[i]

При компиляции процедуры print вновь произойдет ошибка. На этот раз причиной будет функция v[i]. Для корректной компиляции в данном случае необходимо определить вторую функцию.

T operator[](int n) const
{
    if(n < 0 || n >= sz)
        throw n;
    return data[n];
}

Деструктор и его необходимость

Деструктор — коренная особенность C++

Рассмотрим такой случай:

{
    myvector<int> v(100000);
    v[0] = v[99999];
    
}

Внутри v создается массив в динамической памяти на 100000 элементов, а после работы с ним освобождения этого блока не происходит. Возникает утечка памяти. Для борьбы с утечками памяти в таких случаях в C++ был придуман деструктор.

Реализуем деструктор для класса myvector.

template <typename T>
class myvector
{
    T* data;
    int sz;

  public:
    ~myvector()
    {
        delete[] data;
    }
};

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

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

Лекция 12

Конструктор копии

Рассмотрим следующую реализацию класса myvector:

class myvector {
    int size;
    int * data;
    string name;

public:
    myvector(string const & name = "id1")
        : size(8), name(name)
    {
        data = new int [size];
        cout << name << " created\n";
    }

    ~myvector()
    {
        delete [] data;
        cout << name << " killed\n";
    }
};

Проверим работу нашего класса, выполнив следующий код в функции main:

    myvector myv1;
    myvector myv2 = myv1;

В строке myvector myv2 = myv1; мы подразумеваем, что происходит копирование объекта myv1, а в конце выполнения функции main() — удаление двух объектов: myv1 и myv2.

В действительности, при запуске программы происходит ошибка:

id1 created
id1 killed
*** Error in `./main': double free or corruption (fasttop): …

Данная проблема возникает из-за того, что команда myvector myv2 = myv1; выполняет копирование указателя data объекта myv1 в myv2. Таким образом в конце программы одна и та же память освобождается дважды — в деструкторе каждого из объектов.

Возникновение ошибки double free

Для того, чтобы добиться корректного поведения при копировании, существует так называемый конструктор копии. Это специальный конструктор, применяемый для создания нового объекта как копии уже существующего. Реализуем такой конструктор для класса myvector:

myvector(myvector const & other): size(other.size), name(other.name)
{
    data = new int[size];
    copy(other.data, other.data + size, data);
    ++name[2];

    cout << "copy ctor from " << other.name << " to " << name << endl;
}

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

id1 created
copy ctor from id1 to id2
id2 killed
id1 killed

Три случая, когда вызывается конструктор копий

  1. Явное создание нового объекта-копии:

    myvector myv2 = myv1`;
    
  2. Вызов функции с передачей параметра по значению:

    void f(myvector st) {/* … */}
    
  3. Возврат объекта из функции по значению:

    myvector g() {/* … */}
    

Return Value Optimization

Рассмотрим следующий код.

class myvector {
    
public:
};

myvector g() { return myvector(); }

int main()
{
    myvector myv1 = g();
}

Вопрос: сколько будет вызвано конструкторов копий? Ответ: здесь присутствуют случаи 1 и 3 вызова конструктора копий, а значит должны создаваться две копии.

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

Эта оптимизация носит название Return Value Optimization (RVO).

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

Функции-члены, которые генерируются «молча»

Рассмотрим следующий (не очень полезный) класс.

class Empty {};

На самом деле, такое описание класса эквивалентно следующему:

class Empty{
public:
    Empty() {}

    Empty(Empty const &) {/* … */}

    Empty & operator=(Empty const &) {/* … */}

    ~Empty() {/* … */}
};

Здесь присутствуют 4 функции:

Четыре перечисленные функции генерируются автоматически. Деструктор и конструктор копии мы уже изучили, рассмотрим оставшиеся две.

Конструктор по-умолчанию

Конструктор по-умолчанию — это конструктор без параметров, который генерируется автоматически, только тогда, кода явно не определено ни одного другого конструктора в классе (в том числе, конструктора копии).

Это свойство конструктора по-умолчанию может стать причиной неочевидных ошибок. Рассмотрим класс Student и создание объекта этого класса:

class Student
{
    string name;
}

main()
{
    Student s;
}

Этот код компилируется и работает (хотя не очень полезен). В main работает конструктор по умолчанию. Добавим в класс Student конструктор с инициализацией поля.

class Student
{
    string name;
public:
    Student(string const & name) : name(name) {}
}

После этого main перестанет компилироваться, потому что исчезнет конструктор по умолчанию. Аналогичная ошибка возникнет и при попытке объявить массив объектов класса Student:

main()
{
    Student students[3];
}

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

main()
{
    Student students[] = {Student("Vasya"), Student("Petya"),
                                Student("Sasha")};
}

Операция копирующего присваивания

Сгенерированная компилятором реализация функции-члена operator= класса myvector аналогична сгенерированному конструктору копии, который привёл нас к ошибке двойного освобождения памяти.

    myvector myv1;
    myvector myv2;
    myv2 = myv1;

Здесь произойдёт ещё и утечка памяти, так как старое значение указателя data в объекте myv2 потеряется. Значит, необходимо определить operator= самому.

myvector & operator=(myvector const & other)
{
    if (this != &other) {   // Обязательное клише
        delete [] data;     // Нам потребуется новый размер поля data

        size = other.size;
        data = new int[size];
        copy(other.data, other.data + size, data);
        name = other.name;
        ++name[2];
    }

    return *this;
}

Данная реализация далека от идеала. В частности, могут возникнуть проблемы при возникновении исключения в операции new (такое исключение это не такая уж редкая ситуация): из-за delete [] data объект после исключения в new останется в «полуразрушенном» состоянии. В C++ выделяют три уровня гарантий безопасности кода при возникновении исключений:

  1. Базовая гарантия: при возникновении исключения не возникает утечек ресурсов, однако объекты могут находиться в непредсказуемом состоянии.

  2. Сильная гарантия: если во время операции произошло исключение, то объект будет находиться в том же состоянии, что до начала операции.

  3. Без исключений: в данном коде не может возникнуть исключений.

Приведённая версия operator= даёт лишь базовую гарантию. Достаточно несложно изменить её на строгую. Заведём переменную newdata для результата new, а операцию удаления data перенесём в конец функции.

myvector & operator=(myvector const & other)
{
    if (this != &other) {
        size = other.size;
        int * newdata = new int[size];
        copy(other.data, other.data + size, data);
        name = other.name;
        ++name[2];

        delete [] data;
        data = newdata;
    }

    return *this;
}

Идиома copy-and-swap

Идиома copy-and-swap позволяет разрабатывать устойчивые к исключениям операторы присваивания и сокращает количество кода в них ценой определения полезной вспомогательной функции swap (обмен содержимого двух объектов).

Заметим, что реализация operator=, приведённая выше (обе версии), выполняет действия, которые мы уже делали раньше в разных местах программы, а именно в деструкторе и в конструкторе копии. Идиома copy-and-swap опирается на это наблюдение и предполагает реализацию операции копирующего присваивания с использованием конструктора копий. При этом требуется вначале создать вспомогательную функцию-члена swap(myvector & other), для обмена содержимого текущего объекта с объектом other.

class myvector {
    
public:
    myvector & operator=(myvector other) // вызов КК (случай 2)!
    {
        this -> swap(other);

        cout << "copy assigment" << endl;
        return *this;
    }

    void swap(myvector & other)
    {
        std::swap(data, other.data);
        std::swap(size, other.size);
        std::swap(name, other.name);
    }
};

Лекция 13

Метод resize() в классе myvector

/* myvector.h */
#include <algorithm>
class myvector 
{
    int sz;
    T * data;
    

public:
    void resize(int nsize)
    {
        T* ndata = new T[nsize];
        int n = (sz < nsize) ? sz : nsize;
        std::copy(data, data + n, ndata);
        delete[] data;
        data = ndata;
        sz = nsize;
    }
};

Класс matrix

Матрица — вектор векторов.

Структура матрицы 3x4

Допустим нам необходимо создать матрицу 3x4, тогда мы можем попробовать реализовать её следующим образом.

/* main.cpp */
int main()
{
    myvector<myvector<int>> m(3);
      
}

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

/* myvector.h */
class myvector 
{
    
public:
    myvector(int size = 0);
};

Теперь мы имеем 3 экземпляра класса myvector с размером 0. Чтобы увеличить размер всех векторов выполним для каждого из них resize().

/* main.cpp */
int main()
{
    
    for(int i = 0; i < 3; i++)
        m[i].resize(4);
    m[1][2] = 777;
}

В данном случае значение элемента будет присваиваться по ссылке, поэтому передаваемый объект будет очень маленьким.

Рассмотрим, как данная матрица будет выглядеть в памяти.

Матрица 3x4 в памяти

Будет ли данная динамическая память возвращена системе?

При выходе из блока { … m[1][2] = 777; }, будет вызван деструктор ~m() выполняющий delete[] data.

Замечание. Если data является массивом объектов некоторого класса, тогда до возврата своей памяти он вызывает деструкторы всех элементов этого массива. То есть сначала будут освобождены массивы int, а за тем массив data.

Реализация класса matrix

/* matrix.h */
template<typename T>
class matrix
{
    // Так сделать не получится 
    // myvector<myvector<T>> mdata(3);
    // Необходимо писать так
    myvector<myvector<T>> mdata;

public:
    // Вызывать конструктор mdata(m) в теле конструктора       
    // matrix уже поздно, а при объявлении еще рано, поэтому 
    // конструктор необходимо вызывать в списке инициализации
    matrix(int m, int n): mdata(m)
    {
        for(int i = 0; i < m; i++)
            mdata[i].resize(n);
    }
};

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

Деструктор для данного класса писать не надо. Будет сгенерирован деструктор по умолчанию ~matrix() { }.

Правило. Деструкторы всех подобъектов вызываются автоматически в эпилоге (перед закрытием фигурной скобки деструктора основного объекта). Первым вызовется деструктор основного объекта. Деструкторы вызываются в порядке обратном порядку вызова конструкторов. То есть последовательность вызовов конструкторов и деструкторов будет следующая.

  1. Конструктор подобъекта
  2. Конструктор основного объекта
  3. Деструктор основного объекта
  4. Деструктор подобъекта

Операция доступа по индексу в matrix

m[1, 2] — в C++ так писать нельзя

m[1][2] — класс matrix не контролирует [2]

Поэтому лучше всего перегрузить operator(), тогда обращение по индексу будет выглядеть следующим образом m(1, 2).

/* matrix.h */
template<typename T>
class matrix
{
      
public:
    
    T& operator()(int i, int j)
    {
        return mdata[i][j];
    }  
};

Так как operator() будет inline-функцией, то в результате вызов m(1, 2) будет преобразован следующим образом:

m(1, 2) ~ m.mdata[1][2] ~ m.mdata.data[1].data[2]

Конструктор копий и operator= для класса matrix

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

Как работают автоматически сгенерированные конструктор копии и операция присваивания?

Автоматически сгенерированный конструктор копии вызывает конструкторы копий для всех своих полей. Автоматически сгенерированная операция присваивания вызывает операции присваивания для всех своих полей.

Посмотрим, какой будет сгенерирован конструктор копии.

/* matrix.h */
template<typename T>
class matrix
{
    
public:
    
    matrix(const matrix<T> & mm): mdata(mm.mdata) {}
};

Лекция 14

move-конструкторы и move-operator= (C++11)

Для продолжения наших рассуждений нам понадобится реализовать оператор operator+ для класса myvectror(мы этого еще не сделали). Написать его не сложно:

/* myvector.h */
template<typename T>
class myvectror
{
    
public:
    friend
    myvector<T> operator+(const myvectror<T>& v1, const myvectror<T>& v2) 
    {
        myvector<T> v(v1.sz);
        for(int i = 0; i < v1.sz; i++)
            v[i] = v1[i] + v2[i]
        return v;
    }
}



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

/* main.cpp */
myvector<int> v1(3), v2(3);
myvector<int> vv(v1 + v2);

В строке myvector<int> vv(v1 + v2); сначала создается временный объект при вычислении значения v1 + v2, а за тем вызывается конструктор копии myvector(myvector const & other) в который в качестве значения передается, созданный ранее, временный объект. В результате дважды происходит копирование одного и того же объекта.

Нам уже известно, что на деле этого, скорее всего, не произойдет, благодаря, встроенной в большинство современных компиляторов, Return Value Optimization. Однако автоматическая оптимизация не всегда бывает эффективной, поэтому в стандарте C++11 Бьярн Страуструп предложил вынести RVO на уровень языка. Для этого были введены move-конструктор и move-operator=.

Выражение v1 + v2 из нашего примера называется rvalue. Мы не можем записать v1 + v2 = v, поэтому для того, чтобы как-то обращаться к rvalue используется ссылка на на него и обозначается с помощью двойного амперсанда &&, например T && t

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

Реализация move-конструктора будет выглядеть следующим образом:

class myvector
{
  
public:
  myvector(myvector<T>&& v)
  {
    sz = v.sz;
    data = v.data;

    // Не позволит сразу удалить временный объект  
    v.data = nullptr;
  }

  // Так же переписываем деструктор
  ~myvector()
  {
    if(data != nullptr)
      delete[] data;
  }
}

Теперь в строке myvector<int> vv(v1 + v2); компилятор выберет move-конструктор вместо конструктора копии.



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

/* main.cpp */
myvector<int> v1(3), v2(3);
myvector<int> vv;
vv = v1 + v2;

Здесь v1 + v2 – это rvalue, а vvlvalue.

В этих случаях используется move-operator=, который реализуется следующим образом:

class myvector
{
    
public:
    myvector<T>& operator=(myvector<T>&& v)
    {
        if(data != nullptr)
            delete[] data;      
        sz = v.sz;
        data = v.data;
        v.data = nullptr;       
        return *this;
    }
}

Таким образом move-конструктор и move-operator= решают вопрос о том, как уменьшить накладные расходы на копирование переменных.

Ввиду наличия большого количества стандартных классов использовать move-конструкторы приходится редко, однако знание такого механизма необходимо.

Запрет генерации стандартных операторов

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

Для этих целей в C++ предусмотрен механизм запрета генерации стандартных конструкторов и функций. Рассмотрим его на примере класса A:

class A
{
public:
    A(int i) {}

    // Данная запись указывает на необходимость сгенерировать
    // конструктор по умолчанию
    A() = default;

    // Запретить генерацию конструктора по умолчанию
    A(const A&) = delete;

    // А так можно запретить генерацию operator=
    A& operator=(const A&) = delete; 
}  

Класс frac дроби

Создадим новый класс frac, который реализует работу с рациональными дробями.

Экземпляр класса frac хранит число f в виде отношения m/n, где m и n целые числа типа int.

В данном классе необходимо реализовать преобразование объекта frac к типу double. То есть frac f(1, 3); будет эквивалентно double d = 1/3.0;

К примеру, если мы захотим создать функцию Gauss, которая решала бы уравнение вида Ax = b методом Гаусса, нам необходимо, чтобы эта функция могла работать с классом frac

// T может быть равен double
// T может быть равен frac
template <typename T>
Gauss(const matrix<T> &A, const myvector<T> & b)
{
    
}

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

Реализация функции operator+() сводится к сложению двух отношений m1/n1 + m2/n2, при этом недопустимо прямое деление полей m и n, поэтому запишем формулу сложения дробей только с помощью операций сложения, умножения и целочисленного деления.

Формула сложения дробей

Важно помнить, что порядок операций для нас важен – результатом деления всегда должно быть целое число.

В итоге получим следующий код:

class frac
{
    // n - натуральное
    // m - целое
    int m, n;
public:
    frac(int mm = 0, int nn = 1): m(mm), n(nn)
    {
        int nd = nod(m, n);
        m /= nd; n /= nd;
    }
    friend frac operator+(const frac f1, const frac f2)
    {
        int nd = nod(f1.n, f2.n);
        return frac(f2.n/nd*f1.m + f1.n/nd*f2.m, f1.n/nd*f2.n)
    }
    // реализовать operator*
}



Посмотрим на те возможности которые можно реализовать с помощью frac

Конструктор преобразования

В реальной программе, работая с классом frac, мы хотим писать так: frac f = 1;. То есть запись вида f = 2; должна быть эквивалентна f = frac(2);

Для решения этой задачи существует специальный конструктор, который называется конструктором преобразования.

Любой конструктор, который может быть вызван с одним параметром является конструктором преобразования и служит для неявного преобразования параметра к типу объекта данного класса. Это значит, что в нашем случае этот конструктор уже реализован. То есть запись f = f1 + 3; эквивалентна f = f1 + frac(3);, а f = 2 * f2; преобразуется к f = frac(2) * f2; ~ f = frac(2,1) * f2;

Однако обратим внимание на запись frac(2) * f2, здесь умножить целое число на дробь эффективнее, чем делать преобразование frac(2,1) и умножать дробь на дробь.

Поэтому, для ускорения работы перегрузим operator*:


friend
frac operator*(int n, const frac &f)
{
    return frac(n * f.m, f.n);
}

Подвох в конструкторе с одним параметром (конструкторе преобразования)

Рассмотрим такой код:

myvector<int> v(10), v1(10); // 10 нулей
v1 = v + 1;

Понятно, что делая запись v1 = v + 1; мы подразумеваем увеличение размера вектора на единицу и как следствие получение в v1 нового вектора из 11 нулей.

На деле v1 = v + 10; эквивалентно v1 = v + myvector<int>(10);. То есть мы получим еще один вектор из 10 нулей.

Для того чтобы избежать данной ошибки, необходимо запретить преобразовывать 10 к myvector<int>(10). Для этого существуют явные конструкторы преобразования.

Явные конструкторы преобразования

Явный конструктор преобразования задается с помощью ключевого слова explicit, поэтому его еще называют explicit-конструктор.

class myvector
{
    
public:
    explicit myvector(int n) {}
}

Теперь компилятор запретит выражения типа v1 = v + 1;. Если же необходимо сложить два вектора, тогда надо явно указывать выполняемую операцию как v1 = v + myvector<int>(10);.

Операции приведения типа

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

frac f = frac(2, 3);
double d = f;

В данном случае операция double d = f не сработает. Необходимо определить оператор приведения типа operator double(), для того, чтобы f была эквивалентна double(f)

class frac
{
    int m, n;
public:
    operator double()
    {
        return m/(double)n;
    }
};

Теперь, в силу того, что operator double() является inline, запись double d = f; будет заменяться на double d = f.m/(double)f.n;

Лекция 15

Перегрузка операций ввода-вывода

Займемся явной перегрузкой операции operator<<() для класса frac. В C++ в отличие от .NET перегружать нужно operator<<(), а не функцию преобразования класса к строке.

class frac
{
    int m, n;
public:
    friend ostream& operator<<(ostream& os, const frac& f)
    {
        return os << '(' << f.m << ',' << f.n << ')';
    }
};

Наследование

Наследование используется для повторного использования ранее написанного кода.

Рассмотрим уже знакомую нам иерархию:

Схема наследования Person-Student

Наличие динамически выделяемой памяти int* marks в классе Student создает большое количество проблем. Рассмотрим подробнее:

class Student: public Person
{
    string univ;    // Университет
    int* marks;     // Оценки
public:
    Student(const string& name, int age, const string& u, int pl, int ip, int ml) : Person(name, age), univ(u)
    {
        marks = newint[3];
        marks[0] = pl;
        marks[1] = ip;
        marks[2] = ml;
    }

    ~Student() {delete[] marks;}
};

Здесь Person(name, age) - это вызов конструктора предка. Если не написать вызов Person(name, age), то произойдет вызов конструктора по умолчанию, а если его нет произойдет ошибка.

В деструкторе нам необходимо освободить только память занимемую marks - память выделенная для univ и Person будет освобожденна автоматический при вызове соответствующих деструкторов в эпилоге деструктора ~Student.

Порядок вызова конструкторов и деструкторов

  1. Вызов конструктора базового класса
  2. Вызов конструктора копии полей
  3. Вызов конструктора основного объекта
  4. Вызов деструктора базового класса
  5. Вызов деструкторов полей
  6. Вызов деструктора предка

Этот порядок не изменится если поменять местами элементы списка инициализации Person(name, age) и univ(u)

Все это происходит в эпилоге предка

Теперь нам необходимо написать конструктор копии класса Student

Student(const Student& s) : Person(s), univ(s.univ)
{
    marks = new int[3];
    std::copy(s.maks, s.marks + 3, marks);
}

Заметим, что Person(s) будет работать корректно благодаря upcast

Операция присваивания будет реализована несколько сложнее:

Student& operator=(const Student& s)
{
    if(&s != this)
    {
        delete[] marks;
        Person::operator=(s);
        marks = new int[3];
        std::copy(s.marks, s.marks + 3, marks);
        univ = s.univ;
    }
    returm *this;
};

Каждый ресурс под который выделяется память в конструкторе обычно стремятся обернуть объектом класса контролирующим этот ресурс, что упрощает код.

В int* marks выделение памяти происходит вручную, а для автоматического выделения памяти необходимо использовать vector<int> marks.

Теперь код становится значительно проще:

class Student: public Person
{
    string univ;        // Университет
    vector<int> marks;  // Оценки
public:
    Student(const string& name, int age, const string& u, int pl, int ip, int ml) : Person(name, age), univ(u), marks(3)
    {
        marks[0] = pl;
        marks[1] = ip;
        marks[2] = ml;
    }
};

Деструктор теперь писать не надо так как автоматически сгенерируется ~Student(), который вызовет ~marks, ~uvin и ~Person. Конструктор копии класса вызовет конструктор копии предка, а так же конструкторы копии полей класса. operator= вызовет operator= предка и operator= для всех полей класса. И все это будет происходить автоматически.

Вывод. Старайтесь все динамически выделяемые ресурсы оборачивать в отдельные классы

Преобразование в иерархии "предок-потомок"

Person p("Иванов", 20);
Student s("Петров", 19, 2, 9);

p = s;
s = p;

Здесь работает правило: переменной типа предок можно присвоить переменную типа потомок, но не наоборот. Следовательно s = p; не будет выполнено.

p и s в памяти

При присваивании объекта произв класса переменной базового класса происходит обрезание полей произв класса до полей базового класса. То есть произойдет копирование только полей связанных с персоной.

p и s в памяти



Теперь рассмотрим преобразование типов при работе с указателями.

Person* pp = &p;
Student* ss = &s;

p и s в памяти

Возникает вопрос, возможны ли в данном случае следующие операции:

pp = ss;
ss = pp;

Ответ: pp = ss; возможна, а ss = pp; нет.

p и s в памяти

В C++ работает следующее правило: Указателю на базовый класс можно присвоить адрес переменной производного класса, но не наоборот.

Таким образом, если мы напишем Person& rp = s;, тогда rp будет давать доступ только к двум полям.

Именно поэтому в конструкторе копии класса Student не возникало проблем с вызовом конструктора копии Person(s) в списке инициализации.

Лекция 16

Downcast

Пусть в классе Student есть функция get_group(), которая возвращает номер группы студента. Напомним, что её нет в Person.

/* student.h */
class Student : public Person
{
    int group;

public:
    int get_group() const
    {
        return group;
    }
};

Теперь рассмотрим следующую ситуацию:

Person *p = new Student('Иванов', 20, 2, 9);
p -> get_group();

При вызове p -> get_group(); получим ошибку компиляции, так как в Person не определенна функция get_group(). Нужно привести p к типу Student.

// Старый стиль:
((Student*)pp) -> get_group;

// Совеременный стиль:
static_cast<Student*>(pp) -> get_group();

Другая ситуация:

Person & rp = *new Student("Петров", 20, 2, 9);
static_cast<Student &>(rp).get_group();
delete &rp;

Downcast в C++ работает только через указатель или ссылку на объект базового класса.

Полиморфизм

Student s("Иванов", 20, 2, 9);
Person * pp = &s;
pp -> print();

По умолчанию в C++ работает раннее связывание имени метода с конкретным типом. Раннее связывание происходит на этапе компиляции. Таким образом в приведенном выше примере будет вызвана функция print() как функция-член класса Person.

Виртуальные методы в C++

Если же нам необходимо вызвать print() как функцию-член класса Student, мы должны реализовать позднее связывание, которое выполняется на этапе выполнения программы. Для реализации позднего связывания в C++ используются виртуальные методы.

Рассмотрим пример виртуальных функций:

class Person
{
    
public:
    virtual void print()
    {  }
};

class Student: public Person
{
    
public:
    void print()
    {
        
        Person::print();
        
    }
};

Здесь, в классе Student, функция print() так же будет виртуальной - ключевое слово virtual можно не писать т.к у класса- ка данная функция уже определена как виртуальная.

Теперь, когда реализовано позднее связывание, при выполнении строки pp -> print(); из предыдущего примера, print() будет вызвана как функция-член класса Student.

Предостережение: При выполнении следующего кода:

p = s;
p.print();

будет выполнено раннее связывание, несмотря на то, что функция print() виртуальная.

Вывод:

Полиморфизм C++ работает только через указатели и ссылки на объекты базового класса.

Цена виртуальности

Полиморфизм в C++ реализуется с помощью таблиц виртуальных функций - Virtual Methods Table(VMT).

Формула сложения дробей

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

Если в классе и его предках нет виртуальных методов, то в его объекте поле vptr отсутствует(реализуется принцип: не платим за то, что не используем).

Замечание:

В C++ нет общего главного класса-предка, как например, Object в Java/NET?. Это делается в целях повышения эффективности.

Деструкторы и полиморфизм

Как работает new: сначала выделяется память, потом вызывается конструктор.
Как работает деструктор: сначала вызывается деструктор, потом возвращается память.

Рассмотрим следующий код:

Person * pp = new Student("Иванов", 20 2, 9);
pp -> Print();
delete pp;

В данном случае, при выполнении delete pp; будет вызван деструктор ~Person(), что плохо. Деструкторы в C++ тоже могут быть виртуальными, однако по умолчанию они таковыми не являются.

Сделаем деструктор для класса Person виртуальным:

class Person
{
    
    virtual void Print() { ... }
    virtual ~Person() { ... }
}

Теперь, при освобождении памяти на которую ссылается pp, вызовется деструктор ~Student().

Если для класса Student не написать деструктор, то он сгенерируется автоматическим и будем виртуальным.

Правило:

Если в классе есть хотя бы одна виртуальная функция, тогда обязательно делаем его деструктор виртуальным.

Полиморфные контейнеры и клонирование

Полиморфным называется контейнер, состоящий из полиморфных объектов. То есть объектов, в составе которых есть виртуальные методы, переопределяемые в потомках.

Представим себе некий векторный графический редактор. Все фигуры будут наследоваться от класса Shape, с виртуальным методом draw() — отображением фигуры на экран.

class Shape
{
    virtual void draw() {}
    virtual ~Shape() {}
};

Заведем полиморфный контейнер, хранящий все эти разновидности фигур. Полиморфный контейнер — контейнер указателей или ссылок на объекты базового класса. (Поэтому пишем Shape*).

vector<Shape*> v;
v.push_back(new Circle(20, 30, 5));
v.push_back(new Rectangle(10, 10, 20, 20));

Отрисовываем все объекты:

for (Shape* x : v)
    x -> draw();

Удаляем:

for (Shape* x : v)
    delete x;

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

vector<Shape*> v1 = v;  

не даст желаемого результата так как в v1 будут находиться указатели на те же объекты. Решением данной проблемы было бы создание виртуального конструктора, однако для конструкторов действует следующее правило:

Конструкторы в C++ не могут быть виртуальными.

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

vector<Shape*> v1(v.size());
for (int i = 0; i < v.size(); i++)
    v1[i] = v[i] -> clone();

Клонирование полиморфное, так как объект должен клонировать себя, а не объект базового типа. То есть Rectangle клонирует Rectangle, Circle клонирует Circle и т.д.

Для того, чтобы такое копирование работало объявим в классе Shape функцию clone(), а за тем напишем ее реализацию для каждого графического объекта:

class Shape
{
    virtual Shape* сlone() {};
};

class Circle : public Shape
{
    int x, y, z;

public:
    Circle(int xx, int yy, in zz) {}
    Shape * сlone()
    {
        return new Circle(x, y, z);
    }
}

Обратим внимание не то, что Shape нужен только для того, чтобы его наследовать, методы в нем должны быть обязательно переопределены. Чтобы это четко обозначить, пишем:

class Shape {
    virtual void draw() = 0;
    virtual Shape* сlone() = 0;
}

Теперь если не переопределить функции draw() или clone(), то получим ошибку. Такие методы называются чисто виртуальными.

Система RTTI (Runtime Type Identification)

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

dynamic_cast

Как это было в PascalABC.NET:

var p : Person := new Student("Иванов", 20, 2, 9);
var s := p as Student;  // as пытается сделать downcast к Student, иначе nil.

if s <> nil then
    s.get_group();

Теперь рассмотрим тот же пример в C++:

Person * p = new Student("Иванов", 20, 2, 9);
Student * s = dynamic_cast<Student*>(p);

if (s != nullptr)
    s->get_group();

Если виртуальных методов в классе и его предках нет, то dynamic_cast будет работать как static_cast.

Если приведение возможно, тогда dynamic_cast вернет ссылку на Student, иначе nullptr. Так же в случае невозможности приведения будет сгенерировано исключение std::bad_cast. Для его обработки dynamic_cast следует поместить в блок try{…} catch(…){…}:

try{} 
catch(bad_cast &e){}
catch(my_ex &e){}
catch(){}

В последовательности блоков catch должны в начале должны обрабатываться более специфичные исключения.

Операция typeid и структура type_info

В PascalABC.NET: object.GetType()


В С++ тип объекта определяется оператором typeid(). Данный оператор возвращает структуру type_info, которая содержит ==, !=, name().

#include <typeinfo>
typeid(1/2) == typeid(int);

typeid() для полиморфных типов работает полиморфным образом - возвращает динамический тип объекта.

Лекция 17

Стандартная библиотека C++

Иерархия потоковых классов ввода/вывода

Для работы с потоковыми классами ввода/вывода требуется подключение заголовочного файла <iostream>

Иерархия потоковых классов io

Замечание. На примере iostream можно наблюдать множественное наследование (ромбовидное)

Файловые потоки

#include <fstream>
{
    ifstream ifs("a.txt");
    // по-умолчанию открывается как текстовый

    int i, j;
    ifs >> i >> j; // 3_4
} // здесь неявно в деструкторе будет вызван ifs.close()

{
    ofstream ofs("b.txt");
    ofs << i << "  " << j << endl;
    Frac f(1, 2);
    ofs << f;
} // так же вызовется ofs.close()

Благодаря наследованию мы так же можем выводить в файл дроби, аналогично с вводом. То есть нам не надо описывать отдельно ввод/вывод в файл или строку.

Рассмотрим другой пример:

ofstream to1("c.txt", ios_base::app);
while(!ifs.eof())
    getline(ifs, s); // s - имеет тип string

Здесь демонстрируется открытие файла в режиме дозаписи.

В принципе этот код можно заменить на такой:

ofstream to1("c.txt", ios_base::app);
while(ifs)
    getline(ifs, s);

Строковые потоки

Файлы, строки и т.д. являются разными типами данных, а потоки позволяют унифицировать работу с этими данными.

Ввод в строковый поток осуществляется следующим образом

#include <sstream>

ostringstream os;
os << i << " " << j << frac(1, 2);
cout << os;
const char * cc = os.c_str();

Аналогично выполняется вывод:

#include <sstream>

istringstream is("12 345");
is >> i >> j;

Стандартная библиотека шаблонов (STL)

Эта библиотека появилась в C++ не сразу, но достаточно давно.

STL состоит из:

  1. Контейнерные классы (vector<T>, list<T>, set<T>, map<K,V>, queue<T>, stack<T>... ≈10)
  2. Итераторы - обобщенные указатели на элементы контейнера
  3. Алгоритмы (≈60)
  4. Объекты-функции (функторы) -> лямбда-выражения

Общая характкристика STL

  1. Компактность
  2. Эффективность в ущерб безопасности
  3. Не используются наследование и полиморфизм для эффективности
  4. Универсальность
  5. 0 байт в откомпилированном виде т.к вся бибилиотека состоит из заголовочных файлов
  6. Тяжелые сообщения об ошибках

Некоторые алгоритмы

template<typename ItIn, typename ItOut>
void copy(ItIn b, ItIn e, ItOut b1)
{
    while (b != e)
    {
        *b1 = *b;
        ++b;
        ++b1;
    }
} 

Лекция 18

Мощь STL заключается в том, что алгоритмы ничего не знают про контейнеры, а контейнеры ничего не знают про алгоритмы. Для того, чтобы склеить контейнеры и алгоритмы в STL существуют итераторы.

Итератор это любой тип, к которому применимы операции:

Откуда брать итераторы

Рассмотрим использование итератора на примере массива:

int a = {41, 42, 43};
int b[3];
copy(a, a + 3, b);

Если a и b объявлены в одном кадре стека, то правильнее написать так:

int a = {41, 42, 43};
int b[sizeof(a)/sizeof(*a)];
copy(a, a + sizeof(a)/sizeof(*a), b);

А так итератор применяют со стандартным контейнером:

vector<int> v1{1, 2, 3}, v2(3);
copy(v1.begin(), v1.end(), v2.begin());

Здесь begin() это итератор на первый элемент, а end() итератор на "элемент", следующий за последним.

Итераторы в C++11

В С++11 работа с итераторами изменилась,теперь она осуществляется следующим образом:

std::copy(begin(a), end(a), begin(v));

Пример функции печати каждого элемента контейнера:

void print(Cont const & c)
{
    for(auto it = begin(c), e = end(c); it != e; ++it)
        std::cout << *it << ' ';
    std::cout <<std::entl;
}

На самом деле эта функция нарушает философию STL так как в функцию передается контейнер, а не итераторы.

Рассмотрим использование print

vector<int> v {1, 2, 3};
int a[] = {4, 5, 6};
print(v);
print(a);

Здесь функция print нормально отработает для вектора, но для массива она не должна сработать, так как у массива нет begin() и end(). Однако для массива сработает следующая специализация шаблона:

template <class T, size_t N>
T* end(T(&arr)[N]);

Если имеется библиотечный тип myvector такой, что выполнены два условия:

Тогда мы можем написать собственные версии свободных begin/end для этого типа, тогда print станет работать и с ним.

Концепты

Концептом(concept) называется именованный набор ограничений на параметры-типы, отраженный в документации или иным способом.

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

Вложенные типы

Внутри структуры или класса можно описывать вложенные типы, как через typedef, так и через описание других классов. Для доступа к таким типам вне класса, к имени типа добавляется имя вложенного типа и два двоеточия:

std::list<int> l {1, 2, 3};
std::list<int>::iterator it = l.begin();
void f(std::list<int cons * l)
{
    std::list<int>::const_iterator it = l.begin(); 
}

Второй случай использования typename

в C++98 цикл из print выглядел бы так:

template <typename Cont>
void print(Cont const & c)
{
    for (typename c::const_iterator = c.begin(), e = c.end(); it != e; ++it)
        std::cout << *it << ' ';
    std::cout << std::endl;
}

Ключевое слово typename здесь указывает на то, что мы имеем ввиду не статический член Cont, а вложенный тип. Это второй вариант использования этого ключевого слова.

Вложенный класс

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

class myvector {
    template<typename T>
    class myvector_it {
        T* cur;
    public:
        myvector_it(T* cur): cur(cur) {}
        friend bool operator != (...) {...}
        void operator++() { ++cur; }
        T& operator*() {return *cur}
    }
myvector_it begin() {return myvector_it(data);}
myvector_it end() {return myvector_it(data + size);}
}

Лекция 19

vector<int> v {3, 5, 2};
list<int> l {2, 7, 8, 9}; 
int a[10];
auto pa = copy(v.begin(), v.end(), a);

array a

pa = copy(l.begin(), l.end(), pa);

array a

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

Если написать так:

pa = copy(l.end(), l.begin(), pa);

Тогда copy от такой ошибки не застрахован, так же он не застрахован от ошибок в случае такого кода:

pa = copy(l.end(), l.begin(), pa);
pa = copy(l.begin(), l1.end(), pa);

Замечание. Важно помнить, что элемент, на который указывает end() не принадлежит контейнеру, в отличии от begin(), который является итератором на первый элемент контейнера.

Итератор списка

Мы хотим, что copy работал и для списка.

Для работы класса list_iterator нужен listnode<T>

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

template<typename T>
class list_iterator
{
    listnode<T> * cur;
public:
    list_iterator(listnode<T>* c):cur(c){}
    T& operator*() {eturn cur->data;}
    listierator<T>& operator++()
    {
        cur = cur->next;
        return *this;
    }
    template<typename S>
    friend bool operator!=(list_iterator<S>i1, list_iterator<S>i2)
    {
        return i1.cur != i2.cur;
    }
};

Теперь copy будет выглядеть следующим образом

template<typename It, typename It1>
OutIt copy(InIt b, InIt e, OutIt b1)
{
    while(b.cur != e.cur)
        *b1 = b.cut->data;
    ++b1;
    b.cur = b.cur->next;
    return b1;
}

Основные контейнеры STL

Ассоциативные контейнеры:

Конструкторы контейнеров

vector<int> {2, 5, 1, 3};
list<int> {2, 5, 1, 3};
set<int> {2, 5, 1, 3};

// Инициализация диапазоном другого контейнера
int a[]{1, 2, 4, 5, 6};
vector<int> v1(a, a + 3);


v = v1;
l = l1; // присваивание возможно только для объектов одного типа

// Расширенное присваивание
v.assign(l.begin(), l.end());
// Перевыделение памяти


v1 < v2; // Лексикографическое сравнение

Алгоритмы

Алгоритм for_each

Работа:

#include <algorithm>

template<typename InIt, typename Fun>
Fun for_each(InIt b, InIt e, Fun f)
{
    while(b != e)
    {
        f(*b);
        ++b;
    }
    return f;
}

Использование:

// Печать элемента контейнера
void print(int i)
{
    cout << i << " ";
}

list<int> l{1, 3, 5};
for_each(l.begin(), l.end(), print);

Тот же код можно написать с использованием лямбда выражений:

for_each(l.begin(), l.end(), [](int i){cout << i << " ";});

В STL вызов for_each имеет следующий вид:

Fun for_each(InIt first, InIt last, Fun f);

Здесь InIt = list<int>::iterator, Fun = void(*)(int)

Лекция 20

Рассмотрим другой for_each. Сначала напишем такую функцию:

void twice (int & x)
{
    x *= 2;
}
for_each(begin(v), end(v), twice)
// с помощю лямбд
for_each(begin(v), end(v), [](int& x){x *= 2;})

Теперь нам нужен for_each, который будет умножать элементы контейнера на произвольное число.

Захват переменных из внешнего контекста

Изменим код for_each следующим образом:

for_each(begin(v), end(v), [](int& x){x *= a;})

Для того, что бы такой код компилировался нам необходимо указать в списке захвата, что переменная a пришла в функцию извне

int a = 5;
for_each(begin(v), end(v), [=a](int& x){x *= a;})

Так же в списке захвата можно использовать [=] ― таким образом указывается, что все переменные, которые встретятся в теле лямбда функции, захватываются по значению.

Примечание. Лучше, если описание int a будет находится близко к вызову for_each

Сумма элементов контейнера с помощью for_each

int sum = 0;
for_each(begin(v), end(v), [&sum](int x){sum += x;})

Как видим в списке захвата появилась ссылка, поэтому теперь изменение sum в функции будет изменять значение этой переменной.

[&] - все переменные в теле лямбда, не являющиеся параметрами, захватываются по ссылке.

[&, =x] - все переменные в теле лямбда, кроме x, не являющиеся параметрами, захватываются по ссылке.

[=, &sum] - все переменные в теле лямбда, кроме sum, не являющиеся параметрами, захватываются по значению.

Объекты функции (функторы)

На самом деле в C++ лямбда выражения являются синтаксическим сахаром. В действительности лямбда функции с захваченными переменными переводятся объекты функций.

Создадим следующий класс:

class SimHelper
{
public:
    int sum;
    SumHelper(): sum(0) {}
    void operator()(int x)
    {
        sum += x;
    }
}

На самом деле f в теле for_each это экземпляр класса, у которого перегружен operator(), а не функция, как может подумать неопытный разработчик:

auto f = for_each(begin(v), end(v), SumHelper())
cout << f.sum;

Объектом функции называется объект класса с перегруженной операцией operator().

Присваивание лямбда функций переменной

#include <functional>

auto g = [](int x){return x*x;}
// Здесь auto принимает тип std::function<int(int)>, поэтому можно написать так:
// std::function<int(int)> g = [](int x){return x*x;}
cout << g(5);

Виды итераторов

Со всеми итераторами можно совершать следующие действия:

В BidIt добавлены возможности:

C RanIt допустимы те же операции, что и с BidIt плюс:

Важно понимать, что название итератора это только договоренность, принятая в языке.

Алгоритм find

Работа:

template<typename InIt, typename T>
InIt find(InIt b, InIt e, T t)
{
    while(b != e)
        if(*b == t)
            return b;
        ++b;
    return b;
}

Использование:

int a[]{3, 1, 5, 7}
auto it = find(begin(a), end(a), 5)
if (if != end(a))
    cout << "нашли" << *it;
else cout << "Нет";

Алгоритм find_if

Работа:

template<typename InIt, typename Pred>
InIt find_if(InIt b, InIt e, Pred p)
{
    while(b != e)
        if(p(*b))
            return b;
        ++b;
    return b;
}

Предикат это то, что может быть вызвано с одним параметром и вернет bool.

Использование:

int a[]{3, 1, 5, 7}
auto it = find_if(begin(a), end(a), [](int x)->bool{return x%2 == 0})
if (if != end(a))
    cout << "нашли" << *it;
else cout << "Нет";

Лекция 21

all_of

all_of(b, e, pred)

Возвращает true если все элементы удовлетворяют заданному предикату.

equal

equal(b, e, b1) 
{
    while(b != e)
    {
        if (*b != *b1)
            return false;
    ++b;
    ++b1;
    }
    return true;
}

mismatch

если данны две последовательности: 1 5 7 3 1 5 4 8

Возвращается пара итераторов на несовпадающие элементы

pair<InIt, InIt> mistmatch(b, e, b1)
{
    auto p = mistmatch(begin(v), end(v), begin(v1));
    if (p.first == end(v))
        cout << "equals";
    else cout << *(p.first) << *(p.second);
}

Ищет подпоследовательность в последовательности

search(b, e, b1, e1)

copy

OutIt copy(InIt b, InIt e, OutIt b1)
{
    while(b != e) {
        *b1 = *b;
        ++b;
        ++b1;
    }
    return b1;
}
vector<int> v{1, 5, 3};
list<int> l;
copy(begin(v), end(v), begin(l));
// Итератор будет иметь нулевое значение поэтому при попытке обратиться к нему произойдет ошибка.

Итераторы вставки

Таким образом элементы копируются добавляясь в список:

copy(begin(v), end(v), back_inserter(l));
copy(begin(v), end(v), back_inserter_iterator<list<int>>)
template<typename C>
class back_inserter_iterator
{
    C & c;
public:
    back_inserter_iterator(const C & cc): c(cc) {}
    back_inserter_iterator<C>& operator*()
    {
        return *this;
    }
    void operator =(const C::value_type& v)
    {
        c.push_back(v);
    }
    void operator++() {}
};

Теперь из back_inserter_iterator сделает back_inserter:

inline
template<typename C>
back_insert)iterator<C> back_incerter(const C& c)
{
    return back_incert_iterator<C> (c);
}

Так же существует front_inserter, который копирует элементы в начало контейнера, который имеет функцию push_front.

Итерторы потоков

Допустим нам надо копировать последовательность в поток.

vector<int> v{1, 5, 3};
// хочется написать так copy(begin(v), end(v), cout)
// На деле создается обертка над cout
copy(begin(v), end(v), ostream_iterator<int>(cout, " "));
template<typename T>
class ostream_iterator
{
    ostream & os;
    string delim;
public:
    ostream_iterator(cout ostream& oos, const string& d): os(oss), delim(d){}
    void operator++(){}
    void operator=(const T & t)
    {
        os << t << delime;
    }
};

Итератор потока ввода

vector<int> v;
copy(istream_iterator<int>(cin), istream_iterator<int>(), back_incerter(v));

Если здесь файловый поток, то копироваться будут элементы до конца файла, если cin, то концом потока ввода будет символ генерируемый Ctrl+Z

В результате операий изменения контейнера все итераторы этого контейнера станут недействительными.

Лекция 22

Set и Map

Внутреннее представление

set<T> и map<K, V> являются БДП O(log n).
unordered_set, unordered_map<K, V> — O(1), хэш таблица.

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

#include <set>
using namespace std;

set<int> s;
s.insert(1);  // Вставка элемента
s.erase(1);   // Удаление
s.find(1);    // Поиск элемента, возвращает s.end(), если не найден.

Об insert

auto a = s.insert(1);

pair<it, bool>

if (a.second)  // проверяем, вставлено или нет
    cout << *(a.first);

О find

auto it = s.find(1);  // auto ~ set<int>::iterator
if (it == s.end());
    cout << "Не нашли!";
else {
    cout << *it;
    *it = 5; // Запрещено!!1
}

Итерация по set

for (auto x = begin(s); x != end(s); ++x)
    cout << *x << ", ";

Какова стоимость обхождения БДП? begin(s) ~ n log n

Так как каждый узел обходится один раз. По каждому ребру мы движемся либо в прямом порядке, либо в обратном (когда возвращаемся). Поэтому проход по дереву будет ~ 2n, т.е. O(n).

Требования от множества

class Person
{
    string name;
    int age;
    ...
};

set<Person> s;

Так как множество упорядочено, то нужно в Person определить операцию <.

    friend
    bool operator<(const Person & p1, const Person & p2) {
        return p1.name < p2.name;
    }

Можно также написать компаратор, который будет уметь сравнивать объекты типа Person:

class PersonComp
{
    bool operator() (const Person & p1, const Person & p2)
    {
        return p1.name < p2.name;
    }
}

set<Person, PersonComp> s1;

Равенство и эквивалентность

Вопрос: куда делась операция == ?

Заменим ==(x, y) -> Equiv(x, y) ~ !(x < y) & (y < x).

То есть операции < достаточно.

Ассоциативный словарь

#include<map>

map<string, int> m;
m["бегемот"] = 3;
m["крокодил"] = m["какаду"] - 1;

В .NET если в множестве не было пары ("какаду", x), то возникает исключение. Но в C++ вернёт 0. Соответственно необходимо для типа написать конструктор по умолчанию.

Цикл по карте

for (auto x = begin(m); x != end(m); ++x) {
    cout << *x;  // x типа pair<string, int>
    (*x).second = 5;  // можно
}


auto it = m.find("крокодил");
if (it == m.end())
    cout << "Крокодилов нет";
else
    (*it).second += 2;  // завезли два крокодила

m.erase(it); // дерево будет перестраиваться

map

Graph g;

g["Ростов"]["Батайск"] = 10;
g["Ростов"]["Москва"] = 1100;

typedef map<string, maostring, int>> Graph;

Ростов -> Москва (1100) Ростов -> Батайск (10)

//### какая-то фигня

unordered_set s;

как добавится так и будет выводится, хэш таблица операции выполняются быстро

Как хранить там студентов? unordered_set s;

struct Hasher 
{
    size_t operator() (const Person & p)
    {
        return hash<string>()(p.name) ^ hash<int>()(p.age);
    }
}

Хэш функция вычисляется быстро, для одинаковых персон возваращает одно и то же значения. Но для разных можно одно и тоже, но лучше редко. (Скопировать с вики).

hash<string>()(p.name) — обощенный класс hash с типом string, для которого вызывается конструктор и вызывается оператор ().

Можно указать свою специализацию класса hash и радоваться, но мы не будем этого делать.

Лекция 23

Алгоритмы модифицирующие последовательность

transform - map в мире функциональных языков.

PRNG - pseudo-random number generator. см заголовочный файл <random>.

transform(v.begin(), v.end(), v.begin(), [](int x){return x + 1;});
vector<int> v1{1, 2, 3};
int a[] {2, -2, 8};
list<int> l;

transform(begin(v1), end(v1), a, back_inserter(1), std::max<int>)

assert(1 == (list<int>{2, 2, 8}));
template<typename It, typename F>
It remove_if(It b, It e, F f)...
auto it = remove_if(begin(v1), end(v1), [](int x){return !(x%2)});
assert (v1 == (vector<int>{1, 3, 2})); [b, it)
assert (*it == 2);

v1.erase(it, end(v1));
selection_sort(It b, It e)
{
    while(b != e) {
        It min = min_element(b, e);
        swap_iter(b, min);
        ++b;
    }
}
template<typename Cont>
class rnd_it {
    Cont * c;
    vector<int> pos;
    vector<int>::iterator it;

public:
    rnd_it(cont & c): c(&c)
    {
        int n = 0;
        generate_n(back_inserter(pos), c->size(), [&](){
            return n++;
        });
        random_shuffle(begin(pos), end(pos), end(pos));
        it = begin(pos);
    }

    rnd_it&
    operator++() {++it; return *this;}

    typename Cont::value_type&
    operator *(){
        return *std:next(begin(*c), *it);
    }

    friend
    template<typename C>
    bool operator!=(rnd_it<C> const & it1, rnd_it<C> const & it2)
    {

    }
};