Каждая программа на 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.0 — double |
% | 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
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 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;
}
Говорят, что переменная описана (определена), если она объявлена и под неё выделена память. Рассмотрим следующий пример, пусть в проекте есть два файла:
/* 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] = { /* ... */ }; |
При простом определении глобальных сущностей они объединяются в глобальном пространстве имен. Для создания локального пространства имен используется ключевое слово namespace
.
namespace MyNamespace{
... // содержимое пространства имён: типы, функции, что-угодно…
}
Прототипы функций и глобальные переменные в заголовочных файлах, особенно в крупных проектах, необходимо заключать в пространства имен.
/* header.h */
namespace MyNamespace{
extern int n;
void f();
}
*.cpp
-файла в объектный код (файлы *.obj
или *.o
).*.exe
или ELF
).#include "*.cpp"
(грубая ошибка).main()
во всех файлах проектах.main()
.inline
-функции «погибают» при компиляции.#include
— вставляет текст из указанного файла,#define
— задает макроопределение (макрос) с параметрами или без
(во втором случае это просто символическая константа),#undef
— отменяет предыдущее определение,#ifdef
— осуществляет условную компиляцию при определённости макроса,#ifndef
— осуществляет условную компиляцию при неопределённости макроса,#else
— ветка условной компиляции при ложности выражения.#define FLAG1
#ifdef FLAG1
// Код, помещённый здесь, откомпилируется только,
// если определена макроподстановка FLAG1
#else
// Данный код откомпилируется, если макроподстановка FLAG1 не определена
#endif /* FLAG1 */
Include guards — шаблонная конструкция (клише), вставляемая в заголовочный файл.
#ifndef H_FILE_NAME_HPP
#define H_FILE_NAME_HPP
// Содержимое заголовочного файла
#enfif /* H_FILE_NAME_HPP */
Такая кострукция защищает проект от повторного включения прототипов функции и зацикливания на этапе препроцессирования, что, в свою очередь, приводит к сокращению времени компиляции, а в случае зацикливания к корректной сборке проекта.
В режиме включенных предкомпилированных заголовков при компиляции
заголовочных файлов все они попадают в откомпилированном виде в файл
*.pch
(для Visual Studio), тогда при необходимости повторного
использования они не компилируются заново, а берутся из *.pch
. Это
приводит к уменьшению времени компиляции в больших проектах.
Перечисления, согласно стандарту 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);
При объявлении 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;
}
// Создание массива из 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) возвращает размер элемента массива
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
В языке С строки определяются как одномерный массив типа char
.
char str[] = "Hello world!!!";
Это строка из 14 символов, однако размер массива будет равен 15, так как
строки в C заканчиваются символом \0
— нуль терминатор (нулевой байт:
байт, все биты которого равны 0). Иногда такие строки называют
нуль-терминированными.
Недостаток такого подхода состоит в том, что для того чтобы узнать
длину строки необходимо просканировать всю строку до конца,
в поисках \0
— это может занять много времени.
При попытке стандартным образом ввести с консоли значение "Hello world!" в
str
, будет введено только "Hello" (до первого пробела). Чтобы этого не
происходило можно использовать следующую запись:
std::cin.getline(str, 14); // 14 — количество вводимых символов
В 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);
.
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]
:
размер внешнего массива потеряется, а размер внутренних массивов сохранится.
Синонимы (псевдонимы) типов определяются с помощью 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;
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]
Идиома - устойчивое выражение, которое воспринимается как единое целое.
Теперь допустим, нам необходимо сделать следующее:
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;
}
#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
.
int *pi;
*pi = 5
int *pi = new int;
pi = new int;
// Особенно сильно это чувствуется при использовании в циклах
for(;;)
pi = new int;
Если в процессе работы функции выделяется динамическая память, то ее следует освобождать в теле той же функции. Если же указатель на динамически выделенную память передается в качестве результата работы функции, то необходимо озаботиться об освобождении этой памяти вне тела этой функции. Такой принцип работы не всегда очевиден - тем и опасен.
void f()
{
int* pi = new int;
}
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];
Каждый элемент имеет тип int*
и значение NULL
.
for(int i = 0; i < m; i++)
a[i] = new int[n];
// Теперь можно обращаться
a[1][2] ~ *(*(a+1)+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;
Воспользуемся для создания линейного списка шаблон структурой 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<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++ шаблоны компилируются в два этапа:
И на каждом этапе могут возникнуть ошибки.
В С++ компиляция шаблонов проходит в два этапа, а в .NET обобщения компилируются 1 раз.
В С++ в результате компиляции шаблона получается исполняемый код инстанцированных функций, структур и классов. В .NET в результате компиляции обобщения создается исполняемый код самого обобщения т.е в .NET можно создать dll с обобщенным классом.
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>
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++ структурная эквивалентность типов, а не именная.
После использования структуры необходимо освободить выделенную для неё память.
Если этого не сделать, то можно наткнуться на так называемую утечку памяти.
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
происходят следующие действия:
Вектор выделяет чуть больше памяти чем ему требуется для хранения элементов. Поэтому различают логическую длину (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); // Изменить строку не получится
В лекции 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
, дополнительных расходов на её вызов не возникает.
Для класса Date
очень полезными будут операции d++
и ++d
. Рассмотрим реализацию таких операторов.
++a
--a
) (общий случай)Префиксная унарная операция может быть определена двумя способами:
@a ~ a.operator@()
.@a ~ operator@(a)
.Определим эту операцию для класса Date
class Date
{
…
public:
…
// определение префиксного ++
Date & operator++()
{
add_days(1);
return *this;
}
};
a++
a--
)При перегрузке префиксной и постфиксной унарных операций встает вопрос, об интерпретации записи типа operator@
.
В первых версиях языка префиксная и постфиксная операции определялись одинаково, но 1998 году для их разделения был введен фиктивный параметр типа int
.
Постфиксная унарная операция также может быть определена двумя способами:
a@ ~ a.operator@(int)
.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;
}
};
Деструкторы всегда вызываются неявно в определенный момент времени. Для локальных объектов при выходе из блока, а для глобальных при завершении программы.
Принципиальное отличие этого подхода от сборщика мусора: сборщик мусора вызывается в недетерминированный момент времени, а деструкторы всегда вызываются в детерминированный момент времени, когда заканчивается время жизни объекта.
Рассмотрим следующую реализацию класса 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
.
Таким образом в конце программы
одна и та же память освобождается дважды — в деструкторе каждого из объектов.
Для того, чтобы добиться корректного поведения при копировании, существует
так называемый конструктор копии. Это специальный конструктор, применяемый
для создания нового объекта как копии уже существующего.
Реализуем такой конструктор для класса 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
Явное создание нового объекта-копии:
myvector myv2 = myv1`;
Вызов функции с передачей параметра по значению:
void f(myvector st) {/* … */}
Возврат объекта из функции по значению:
myvector g() {/* … */}
Рассмотрим следующий код.
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 функции:
Empty()
― конструктор без параметров,Empty(Empty const &)
― конструктор копий,Empty & operator=(Empty const &)
― операция копирующего присваивания,~Empty()
― деструктор.Четыре перечисленные функции генерируются автоматически. Деструктор и конструктор копии мы уже изучили, рассмотрим оставшиеся две.
Конструктор по-умолчанию — это конструктор без параметров, который генерируется автоматически, только тогда, кода явно не определено ни одного другого конструктора в классе (в том числе, конструктора копии).
Это свойство конструктора по-умолчанию может стать причиной неочевидных ошибок.
Рассмотрим класс 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++
выделяют три уровня гарантий безопасности кода при возникновении исключений:
Базовая гарантия: при возникновении исключения не возникает утечек ресурсов, однако объекты могут находиться в непредсказуемом состоянии.
Сильная гарантия: если во время операции произошло исключение, то объект будет находиться в том же состоянии, что до начала операции.
Без исключений: в данном коде не может возникнуть исключений.
Приведённая версия 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 позволяет разрабатывать устойчивые к исключениям операторы присваивания и сокращает количество кода в них ценой определения полезной
вспомогательной функции 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);
}
};
/* 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;
}
};
Матрица — вектор векторов.
Допустим нам необходимо создать матрицу 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;
}
В данном случае значение элемента будет присваиваться по ссылке, поэтому передаваемый объект будет очень маленьким.
Рассмотрим, как данная матрица будет выглядеть в памяти.
Будет ли данная динамическая память возвращена системе?
При выходе из блока { … m[1][2] = 777; }
, будет вызван деструктор ~m()
выполняющий delete[] data
.
Замечание. Если data
является массивом объектов некоторого класса, тогда до возврата своей памяти он вызывает деструкторы всех элементов этого массива.
То есть сначала будут освобождены массивы int
, а за тем массив data
.
/* 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() { }
.
Правило. Деструкторы всех подобъектов вызываются автоматически в эпилоге (перед закрытием фигурной скобки деструктора основного объекта). Первым вызовется деструктор основного объекта. Деструкторы вызываются в порядке обратном порядку вызова конструкторов. То есть последовательность вызовов конструкторов и деструкторов будет следующая.
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.h */
template<typename T>
class matrix
{
…
public:
…
matrix(const matrix<T> & mm): mdata(mm.mdata) {}
};
Для продолжения наших рассуждений нам понадобится реализовать оператор 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, а vv
– lvalue.
В этих случаях используется 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
хранит число 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;
Займемся явной перегрузкой операции 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 << ')';
}
};
Наследование используется для повторного использования ранее написанного кода.
Рассмотрим уже знакомую нам иерархию:
Наличие динамически выделяемой памяти 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
.
Этот порядок не изменится если поменять местами элементы списка инициализации 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;
не будет выполнено.
При присваивании объекта произв класса переменной базового класса происходит обрезание полей произв класса до полей базового класса. То есть произойдет копирование только полей связанных с персоной.
Теперь рассмотрим преобразование типов при работе с указателями.
Person* pp = &p;
Student* ss = &s;
Возникает вопрос, возможны ли в данном случае следующие операции:
pp = ss;
ss = pp;
Ответ: pp = ss;
возможна, а ss = pp;
нет.
В C++ работает следующее правило: Указателю на базовый класс можно присвоить адрес переменной производного класса, но не наоборот.
Таким образом, если мы напишем Person& rp = s;
, тогда rp
будет давать доступ только к двум полям.
Именно поэтому в конструкторе копии класса Student
не возникало проблем с вызовом конструктора копии Person(s)
в списке инициализации.
Пусть в классе 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
.
Если же нам необходимо вызвать 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()
, то получим ошибку. Такие методы называются чисто виртуальными.
Если в классе имеются виртуальные методы, то на этапе выполнения можно идентифицировать динамический тип переменной. Для этого существует два средства:
Как это было в 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()
для полиморфных типов работает полиморфным образом - возвращает динамический тип объекта.
Для работы с потоковыми классами ввода/вывода требуется подключение заголовочного файла <iostream>
Замечание. На примере 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;
Эта библиотека появилась в C++ не сразу, но достаточно давно.
STL состоит из:
vector<T>
, list<T>
, set<T>
, map<K,V>
, queue<T>
, stack<T>
... ≈10)template<typename ItIn, typename ItOut>
void copy(ItIn b, ItIn e, ItOut b1)
{
while (b != e)
{
*b1 = *b;
++b;
++b1;
}
}
Мощь 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()
итератор на "элемент", следующий за последним.
В С++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
begin
/ end
)Тогда мы можем написать собственные версии свободных 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);}
}
vector<int> v {3, 5, 2};
list<int> l {2, 7, 8, 9};
int a[10];
auto pa = copy(v.begin(), v.end(), a);
pa = copy(l.begin(), l.end(), pa);
Что будет, если в том контейнере куда мы копируем будет недостаточно места? Будет перезаписана чужая память из-за отсутствия контроля выхода за границу.
Если написать так:
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;
}
vector<T>
- внутри это массивlist<T>
- внутри это двусвязный списокstack<T>
- различная реализация контейнераqeueu<T>
- различная реализация контейнераdeque<T>
- это абстрактный тип данных с эффективными операциями доступа к началу и конку и достаточно эффективной операцией доступа по индексуАссоциативные контейнеры:
map<K, V>
- это аналог SortedDicionary<K, V>
в .NET, по сути бинарное дерево поиска, то есть скорость поиска log(n)set<K>
- множество, так же является бинарным деревом поиска multimap<K,V>
- map
с повторяющимися значениямиmultiset<K>
- set
с повторяющимися ключамиunordered_map<K, V>
- хеш-таблица O(1)unordered_set<K>
- хеш-таблица O(1)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)
Рассмотрим другой 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);
InIt
OutIt
FwdIt
BidIt
RanIt
Со всеми итераторами можно совершать следующие действия:
it1 == it2
, it1 != it2
it++
, ++it
a = *it
В BidIt
добавлены возможности:
it--
, --it
C RanIt
допустимы те же операции, что и с BidIt
плюс:
it[i]
it1 < it2
, it1 > it2
it2 - it1 = n
it + n
, it - n
Важно понимать, что название итератора это только договоренность, принятая в языке.
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 << "Нет";
all_of(b, e, pred)
Возвращает true если все элементы удовлетворяют заданному предикату.
equal(b, e, b1)
{
while(b != e)
{
if (*b != *b1)
return false;
++b;
++b1;
}
return true;
}
если данны две последовательности: 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)
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
В результате операий изменения контейнера все итераторы этого контейнера станут недействительными.
set<T>
и map<K, V>
являются БДП O(log n).
unordered_setunordered_map<K, V>
— O(1), хэш таблица.
Операции (таблица нужна со ссылками на доки):
#include <set>
using namespace std;
set<int> s;
s.insert(1); // Вставка элемента
s.erase(1); // Удаление
s.find(1); // Поиск элемента, возвращает s.end(), если не найден.
auto a = s.insert(1);
pair<it, bool>
if (a.second) // проверяем, вставлено или нет
cout << *(a.first);
auto it = s.find(1); // auto ~ set<int>::iterator
if (it == s.end());
cout << "Не нашли!";
else {
cout << *it;
*it = 5; // Запрещено!!1
}
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
как добавится так и будет выводится, хэш таблица операции выполняются быстро
Как хранить там студентов?
unordered_set
struct Hasher
{
size_t operator() (const Person & p)
{
return hash<string>()(p.name) ^ hash<int>()(p.age);
}
}
Хэш функция вычисляется быстро, для одинаковых персон возваращает одно и то же значения. Но для разных можно одно и тоже, но лучше редко. (Скопировать с вики).
hash<string>()(p.name)
— обощенный класс hash с типом string, для которого вызывается конструктор и вызывается оператор ()
.
Можно указать свою специализацию класса hash и радоваться, но мы не будем этого делать.
copy
, transform
, replace
, ...)transform
- map
в мире функциональных языков.
swap
, swap_iter
, random_shuffle
, shuffle
+ PRNG, ...)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)
{
}
};