main
Дмитрий Козлюк 2 лет назад
Родитель d57d26497a
Сommit c1fc5cfd63

@ -4,7 +4,7 @@
## Лекции ## Лекции
1. Основы языка C++ 1. [Основы языка C++](lectures/lecture01)
2. Системы контроля версий 2. Системы контроля версий
3. Структурирование кода и данных (функции, указатели) 3. Структурирование кода и данных (функции, указатели)
4. Сборка программ из нескольких файлов 4. Сборка программ из нескольких файлов

@ -0,0 +1,441 @@
# Лекция 1. Основы C++
Эта лекция каждый год разная. Ниже конспект занятия 20.02.
## Как писать код
> Любой дурак может написать код, понятный компьютеру.
> Хорошие программисты пишут код, понятный людям.
*Мартин Фаулер*
Знать конструкции языка программирования — как знать переводы слов.
Это необходимо, чтобы общаться, но ясной речью мы называем не набор слов,
а правильно выбранные и расположенные слова.
Написание программы начинается с идеи и подобно переводу.
Чем точнее конструкция языка будет соответствовать идее,
которую хочется выразить, тем проще будет разобраться в программе.
Код читается гораздо чаще, чем пишется, поэтому писать понятно выгодно
в плане экономии усилий и другим, и себе через некоторое время.
Во-первых, это выбор правильных конструкций и типов.
Например, есть переменная, содержащая количество значений.
Можно было бы объявить ее как `int n`.
Но `int` — это знаковый тип, то есть читатель, увидев такую строку,
задумается, что здесь означает знак, а когда разберется, что знак не нужен,
должен будет на протяжении оставшейся программы помнить об этом.
Беззнаковый тип подойдет лучше: `unsigned int n`.
«Но почему не `long`, это осознанный выбор?» — спросит читатель.
Если объявить `size_t n`, вопроса не возникнет:
это переменная, которая содержит количество или размер какого-то набора.
Но что это за количество?
**Правильное именование критически важно.**
Если это количество чисел, имеет смысл назвать переменную `number_count`.
Не нужно использовать имена из одной буквы,
если это не дословная реализация математических выкладок.
Не нужно ispolzovat translit, потому что это трудно читать.
Наконец, код нужно опрятно оформлять.
Пример плохого оформления:
с первого взгляда трудно понять, как между собой связаны `if` и `for`,
сложно продраться через нагромождение операторов в формуле.
```cpp
if((i>0)&&(bins[i-1]<bins[i])){
for(int j=0; j<bins[i]-1; j++) {
cout<<'*';
}
```
Пример хорошего оформления того же кода:
вложенность блоков выделена отступами,
вокруг операторов стоят пробелы.
```cpp
if ((i > 0) && (bins[i - 1] < bins[i])) {
for (int j = 0; j < bins[i] - 1; j++) {
cout<<'*';
}
```
В среде CodeBlock можно в контекстном меню (по правой кнопке мыши)
выбрать пункт «Format use AStyle», чтобы автоматически расставить отступы.
Однако стоит выработать привычку сразу писать код аккуратно.
Соглашения о том, как именно форматировать код, например, ставить ли `{`
на той же строке, что и `if`, или на следующей, называются соглашением
о кодировании (coding style).
Обычно команда принимает какой-нибудь стиль и все участники ему следуют.
Ни в каком реальном проекте код как попало не пишут.
## Повторение конструкций C++
### Скобки
Фигурные скобки лучше использовать всегда, даже если они необязательны:
```cpp
if (условие) {
единственная инструкция, формально не требующая скобок
}
```
В этом случае, когда понадобится доработать код и расширить тело под `if`,
нет риска забыть добавить скобки и получить корректную с точки зрения языка,
но логически неправильную программу.
Лучше также ставить скобки в выражениях, чтобы читателю не приходилось
вспоминать приоритеты операций. Тому, кто пишет код, может быть очевидно то,
что читатель при беглом взгляде не вспомнит.
```cpp
if ((foo != 1) && (bar > 0)) // внутренние скобки сугубо для наглядности
```
### Переменные
Переменные лучше объявлять как можно ближе к месту первого использования,
а не все сразу в начале программы или функции.
Дело в том, что при чтении кода программист держи в голове контекст:
представление о том, какие есть переменные и чему они могут быть равны
в данном месте программы. Чем меньше контекст, тем проще разобраться.
Лучше давать переменным осмысленные начальные значения, если возможно.
### `switch`
Выражение, по которому делается переход, должно быть интегрального типа,
в это входят целочисленные типы, `char` и перечисления (`enum`).
Значения в `case` должны быть константами. Это не могут быть переменные.
Как выполнять одни и те же действия для нескольких значений?
```cpp
char answer;
cout << "Да (y/Y) или нет (n/N)? ";
cin >> answer;
switch (answer) {
case 'y':
case 'Y':
cout << "Ответ утвердительный.\n";
break;
case 'n':
case 'N':
cout << "Ответ отрицательный.\n";
break;
default:
cout << "Ответ неопределенный.\n";
}
```
Допустим, был совершен переход к `case 'y'`.
Команды будут выполняться, пока не встретится `break`.
Строка `case 'Y'` будет просто проигнорирована.
**Это практически единственный случай, когда имеет смысл `case` без `break`.**
Исключения из правила встречаются, но очень редко.
### `using namespace std;`
В большой программе велика вероятность того, что два типа или две функции,
которые доступны одновременно, будет логично назвать одинаково.
Хуже того, это может случиться не в разных частях одной программы,
когда можно найти компромисс, а между программой и библиотекой.
Например, в программе может быть функция `print()` для печати чего-нибудь.
Это значит, что если такая функция появится в стандартной библиотеке —
а она недавно появилась — возникнет конфликт
(перегрузки функций рассматривать здесь не будем).
Аналогия из повседневной жизни — тёзки.
Решение — фамилии: есть полное имя для точного обращения,
но в большинстве случаев удобно и возможно обращаться только по имени.
Пространства имен (namespaces) играют ту же роль, что и фамилии.
Например, все типы, функции и переменные стандартной библиотеки
находятся в пространстве имен `std` (standard): `cout` — это `std::cout` и т. д.
При использовании конструкции `using namespace std;`
компилятор при обращении к имени [`foo`](https://en.wikipedia.org/wiki/Foobar)
сначала попробует найти `foo`, а если не найдет, то `std::foo`.
Мы вернемся к пространствам имен через пару лекций,
когда будем изучать программы из нескольких модулей.
## Тип данных `vector<T>`
Если нужен массив, длина которого неизвестна на этапе компиляции,
можно создать этот массив динамически оператором `new`:
```cpp
size_t count;
cin >> count;
float* numbers = new float[count];
// ...
delete[] numbers;
```
Однако есть ряд проблем:
* Вместе с массивом `numbers` нужно везде передавать его длину `count`.
* Нужно не забыть освободить память под `numbers` оператором `delete[]`.
Если этого не сделать, память останется занятой, хотя программа уже
не будет с ней работать. Это называется утечкой памяти (memory leak).
* Если нужно добавить элемент, нужно выделить новый динамический массив,
скопировать туда элементы старого и освободить старый.
Поэтому в современных программах на C++
динамическую память стараются не использовать напрямую.
Вместо этого применяют более удобные типы данных,
которые могут использовать динамическую память внутри себя,
но с точки зрения программиста лишены перечисленных недостатков.
Например, уже известный тип `string` делает удобной работу
с динамическим массивом символов (элементов типа `char`).
Новый тип для работы с динамическими массивами из любых элементов — `vector<T>`.
Для его использования нужно подключить заголовочный файл:
```cpp
#include <vector>
```
При объявлении переменной-вектора нужно указать тип элементов в угловых скобках:
```cpp
vector<float> numbers;
```
При такой форме, без указания начального значения, вектор будет пуст
(то есть будет иметь 0 элементов). Можно сразу указать размер `count`:
```cpp
vector<float> numbers(count);
```
Значение `count` должно быть уже задано в этот момент.
Частая ошибка на лабораторных работах:
```cpp
size_t count;
vector<float> numbers(count); // ошибка: значение count еще не введено!
cin >> count;
```
Вектор автоматически инициализирует свои элементы-числа нулями.
Можно создать вектор, который будет содержать заданный набор элементов.
Это может быть удобно в примерах и тестах:
```cpp
vector<float> numbers{1, 2, 3, 4, 5};
```
Память, выделенную под элементы вектора, не требуется явно освобождать,
как это было с динамическим массивом и оператором `delete[]`.
Как только переменная `numbers` выйдет из области видимости
(из блока, где она объявлена), память будет автоматически освобождена.
К элементам вектора можно получить доступ по индексу, как с массивом:
```cpp
cin >> numbers[3];
cout << numbers[3];
```
При доступе по индексу не проверяется, что он в допустимых границах.
Узнать текущий размер вектора можно методом `size()`, как у `string`:
```cpp
for (size_t i = 0; i < numbers.size(); i++) {
cin >> numbers[i];
}
```
Первый элемент вектора можно получить как `numbers[0]` или `numbers.front()`
Последний элемент можно получить как `numbers[numbers.size() - 1]`
или как `numbers.back()`.
Разумеется, первый и последний элемент есть только при `numbers.size() > 0`.
Здесь показан пример перебора элементов вектора по индексу.
Но код программы должен быть как можно ближе к своему смыслу.
Здесь написано: увеличивать значение индекса, пока оно не превысит такое-то,
а то, что этот код перебирает элементы вектора, видно лишь косвенно.
Для перебора векторов (и других коллекций) есть более ясная форма цикла `for`,
**диапазонный `for` (range-based for-loop):**
```cpp
for (float number : numbers) {
cout << number;
}
```
Тело цикла будет выполнено для каждого элемента `numbers`,
который в теле цикла будет иметь имя `number`.
Иногда требуется увеличить или усечь вектор до определенного размера.
Метод `resize()` изменяет размер вектора на заданный, выделяя при необходимости
новую память и заполняя новые элементы нулями:
```cpp
numbers.resize(count * 2);
```
Иногда невозможно заранее сказать размер вектора, например,
если числа считываются, пока не будет введен 0.
Метод `push_back()` добавляет значение в конец вектора, увеличивая его размер:
```cpp
float number;
while (true) {
cin >> number;
if (number == 0) {
break;
}
numbers.push_back(number);
}
```
Добавить значение в начало вектора, или в любое другое его место,
можно методом `insert(позиция вставки, новый элемент)`:
```cpp
numbers.insert(0, 42);
```
Элементы после мета вставки будут автоматически сдвинуты.
Очевидно, что это определенная работа, которая хоть и не видна в коде,
но замедляет выполнение.
Поэтому лучше часто не делать эту операцию с векторами,
а если алгоритму это необходимо, возможно, найдется лучший тип, чем вектор.
В векторе можно искать значение:
```cpp
auto it = numbers.find(42);
```
Каков тип переменной `it`? К сожалению, это не индекс элемента.
При работе с векторами и другими контейнерами стандартной библиотеки
широко применяются так называемые **итераторы,** именно им является `it`.
Итератор — это обобщение указателя. Итератор представляет место элемента.
Они придуманы и используются затем, чтобы одинаковый код мог работать
как с простыми структурами данных, такими как вектор, так и с более сложными.
Например, имея итератор `it`, можно переместить его к следующему элементу
кодом `++it`. Для вектора переход к следующему элементу — это просто увеличение
указателя или индекса, поэтому замена их на итераторы не кажется ценной.
Однако, например, для связанного списка `list<T>`
переход к следующему элементу — совсем другая операция,
но `list<T>` тоже использует итераторы и переход тоже делается `++it`.
Благодаря этому, можно заменить тип `numbers` с `vector<float` на `list<float>`,
а старый код, использующий итераторы, продолжит работать.
Итак, если элемент `42` нашелся в `numbers`, `it` будет итератором,
представляющим позицию этого элемента. А если `42` нет в `numbers`?
Метод `find()` вернет специальное значение итератора:
```cpp
auto it = numbers.find(42);
if (it == numbers.end()) {
cout << "Элемент не найден.\n";
} else {
cout << *it; // 42
}
```
В примере также показано разыменование итератора `*it`, которое работает так же,
как разыменование указателя, то есть будет получено число 42.
Проверка, что `it` не равен `numbers.end()` перед тем, как пользоваться `it`,
похожа на проверку указателя на `nullptr` (`NULL`).
Однако `numbers.end()` не подобен нулевому указателю.
Это специальный итератор, представляющий позицию *за концом* вектора:
```
numbers.size() == 5
|←-----------------→|
+---+---+---+---+---+
| 1 | 2 | 3 | 4 | 5 |
+---+---+---+---+---+
↑ ↑
numbers.begin() numbers.end()
```
Аналогично, метод `begin()` возвращает итератор, представляющий первый элемент.
Схематично можно сказать, что эти итераторы описывают весь вектор
как полуинтервал `[begin; end)`.
Зная об этих двух итераторах, а также о том, что итератор можно сдвигать
к следующему элементу, можно написать такой код:
```cpp
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
cin >> *it; // ввод значения элемента, на котором находится it
}
```
Изначально `it` стоит на начальном элементе, затем перемещается к следующему,
пока не окажется за концом вектора. То есть это снова цикла перебора элементов.
На самом деле, диапазонный цикл `for` является просто более наглядной формой
записи для такого цикла **(синтаксическим сахаром, syntax sugar).**
Иногда такая явная форма бывает удобнее.
Рассмотрим метод для удаления элемента из вектора:
```cpp
numbers.erase(it);
```
Он принимает итератор, находящийся на элементе, который нужно удалить.
Сложность возникает, если нужно делать это в цикле:
```cpp
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
if (*it % 2 == 0) {
numbers.erase(it);
}
}
```
Он не будет работать правильно.
Допустим, встретился элемент с четным значением,
и для `it` был вызван `erase()`.
После этого элемента, на котором стоял `it`, больше нет,
то есть `it` больше не является корректным значением итератора —
говорят, что `erase()` его **инвалидирует (invalidate).**
Применять `++it` к инвалидированному итератору не имеет смысла.
Как исправить ошибку?
Метод `erase()` возвращает итератор, стоящий сразу за удаленным элементом,
то есть либо на следующем оставшемся элементе, либо `numbers.end()`,
если удаленный элемент был последним.
Таким образом, правильный алгоритм будет таким:
если нужно удалить элемент,
то следующим значением `it` будет результат `erase()`,
иначе следующее значение нужно получать через `++it`:
```cpp
auto it = numbers.begin()
while (it != numbers.end()) {
if (*it % 2 == 0) {
it = numbers.erase(it);
} else {
++it;
}
}
```
Одна из частых задач — упорядочить вектор по возрастанию.
Для этого можно воспользоваться стандартной функцией `sort()`:
```cpp
#include <algorithm>
// ...
sort(numbers.begin(), numbers.end());
// здесь numbers уже отсортирован
```
Загрузка…
Отмена
Сохранить