|
2 лет назад | |
---|---|---|
.. | ||
README.md | 2 лет назад |
README.md
Лекция 1. Основы C++
Эта лекция каждый год разная. Ниже конспект занятия 20.02.
Как писать код
Любой дурак может написать код, понятный компьютеру. Хорошие программисты пишут код, понятный людям.
Мартин Фаулер
Знать конструкции языка программирования — как знать переводы слов. Это необходимо, чтобы общаться, но ясной речью мы называем не набор слов, а правильно выбранные и расположенные слова. Написание программы начинается с идеи и подобно переводу. Чем точнее конструкция языка будет соответствовать идее, которую хочется выразить, тем проще будет разобраться в программе. Код читается гораздо чаще, чем пишется, поэтому писать понятно выгодно в плане экономии усилий и другим, и себе через некоторое время.
Во-первых, это выбор правильных конструкций и типов.
Например, есть переменная, содержащая количество значений.
Можно было бы объявить ее как int n
.
Но int
— это знаковый тип, то есть читатель, увидев такую строку,
задумается, что здесь означает знак, а когда разберется, что знак не нужен,
должен будет на протяжении оставшейся программы помнить об этом.
Беззнаковый тип подойдет лучше: unsigned int n
.
«Но почему не long
, это осознанный выбор?» — спросит читатель.
Если объявить size_t n
, вопроса не возникнет:
это переменная, которая содержит количество или размер какого-то набора.
Но что это за количество?
Правильное именование критически важно.
Если это количество чисел, имеет смысл назвать переменную number_count
.
Не нужно использовать имена из одной буквы,
если это не дословная реализация математических выкладок.
Не нужно ispolzovat translit, потому что это трудно читать.
Наконец, код нужно опрятно оформлять.
Пример плохого оформления:
с первого взгляда трудно понять, как между собой связаны if
и for
,
сложно продраться через нагромождение операторов в формуле.
if((i>0)&&(bins[i-1]<bins[i])){
for(int j=0; j<bins[i]-1; j++) {
cout<<'*';
}
Пример хорошего оформления того же кода: вложенность блоков выделена отступами, вокруг операторов стоят пробелы.
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++
Скобки
Фигурные скобки лучше использовать всегда, даже если они необязательны:
if (условие) {
единственная инструкция, формально не требующая скобок
}
В этом случае, когда понадобится доработать код и расширить тело под if
,
нет риска забыть добавить скобки и получить корректную с точки зрения языка,
но логически неправильную программу.
Лучше также ставить скобки в выражениях, чтобы читателю не приходилось вспоминать приоритеты операций. Тому, кто пишет код, может быть очевидно то, что читатель при беглом взгляде не вспомнит.
if ((foo != 1) && (bar > 0)) // внутренние скобки сугубо для наглядности
Переменные
Переменные лучше объявлять как можно ближе к месту первого использования, а не все сразу в начале программы или функции. Дело в том, что при чтении кода программист держи в голове контекст: представление о том, какие есть переменные и чему они могут быть равны в данном месте программы. Чем меньше контекст, тем проще разобраться.
Лучше давать переменным осмысленные начальные значения, если возможно.
switch
Выражение, по которому делается переход, должно быть интегрального типа,
в это входят целочисленные типы, char
и перечисления (enum
).
Значения в case
должны быть константами. Это не могут быть переменные.
Как выполнять одни и те же действия для нескольких значений?
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
сначала попробует найти foo
, а если не найдет, то std::foo
.
Мы вернемся к пространствам имен через пару лекций, когда будем изучать программы из нескольких модулей.
Тип данных vector<T>
Если нужен массив, длина которого неизвестна на этапе компиляции,
можно создать этот массив динамически оператором new
:
size_t count;
cin >> count;
float* numbers = new float[count];
// ...
delete[] numbers;
Однако есть ряд проблем:
-
Вместе с массивом
numbers
нужно везде передавать его длинуcount
. -
Нужно не забыть освободить память под
numbers
операторомdelete[]
. Если этого не сделать, память останется занятой, хотя программа уже не будет с ней работать. Это называется утечкой памяти (memory leak). -
Если нужно добавить элемент, нужно выделить новый динамический массив, скопировать туда элементы старого и освободить старый.
Поэтому в современных программах на C++ динамическую память стараются не использовать напрямую. Вместо этого применяют более удобные типы данных, которые могут использовать динамическую память внутри себя, но с точки зрения программиста лишены перечисленных недостатков.
Например, уже известный тип string
делает удобной работу
с динамическим массивом символов (элементов типа char
).
Новый тип для работы с динамическими массивами из любых элементов — vector<T>
.
Для его использования нужно подключить заголовочный файл:
#include <vector>
При объявлении переменной-вектора нужно указать тип элементов в угловых скобках:
vector<float> numbers;
При такой форме, без указания начального значения, вектор будет пуст
(то есть будет иметь 0 элементов). Можно сразу указать размер count
:
vector<float> numbers(count);
Значение count
должно быть уже задано в этот момент.
Частая ошибка на лабораторных работах:
size_t count;
vector<float> numbers(count); // ошибка: значение count еще не введено!
cin >> count;
Вектор автоматически инициализирует свои элементы-числа нулями. Можно создать вектор, который будет содержать заданный набор элементов. Это может быть удобно в примерах и тестах:
vector<float> numbers{1, 2, 3, 4, 5};
Память, выделенную под элементы вектора, не требуется явно освобождать,
как это было с динамическим массивом и оператором delete[]
.
Как только переменная numbers
выйдет из области видимости
(из блока, где она объявлена), память будет автоматически освобождена.
К элементам вектора можно получить доступ по индексу, как с массивом:
cin >> numbers[3];
cout << numbers[3];
При доступе по индексу не проверяется, что он в допустимых границах.
Узнать текущий размер вектора можно методом size()
, как у string
:
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):
for (float number : numbers) {
cout << number;
}
Тело цикла будет выполнено для каждого элемента numbers
,
который в теле цикла будет иметь имя number
.
Иногда требуется увеличить или усечь вектор до определенного размера.
Метод resize()
изменяет размер вектора на заданный, выделяя при необходимости
новую память и заполняя новые элементы нулями:
numbers.resize(count * 2);
Иногда невозможно заранее сказать размер вектора, например,
если числа считываются, пока не будет введен 0.
Метод push_back()
добавляет значение в конец вектора, увеличивая его размер:
float number;
while (true) {
cin >> number;
if (number == 0) {
break;
}
numbers.push_back(number);
}
Добавить значение в начало вектора, или в любое другое его место,
можно методом insert(позиция вставки, новый элемент)
:
numbers.insert(0, 42);
Элементы после мета вставки будут автоматически сдвинуты. Очевидно, что это определенная работа, которая хоть и не видна в коде, но замедляет выполнение. Поэтому лучше часто не делать эту операцию с векторами, а если алгоритму это необходимо, возможно, найдется лучший тип, чем вектор.
В векторе можно искать значение:
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()
вернет специальное значение итератора:
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)
.
Зная об этих двух итераторах, а также о том, что итератор можно сдвигать к следующему элементу, можно написать такой код:
for (auto it = numbers.begin(); it != numbers.end(); ++it) {
cin >> *it; // ввод значения элемента, на котором находится it
}
Изначально it
стоит на начальном элементе, затем перемещается к следующему,
пока не окажется за концом вектора. То есть это снова цикла перебора элементов.
На самом деле, диапазонный цикл for
является просто более наглядной формой
записи для такого цикла (синтаксическим сахаром, syntax sugar).
Иногда такая явная форма бывает удобнее. Рассмотрим метод для удаления элемента из вектора:
numbers.erase(it);
Он принимает итератор, находящийся на элементе, который нужно удалить. Сложность возникает, если нужно делать это в цикле:
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
:
auto it = numbers.begin()
while (it != numbers.end()) {
if (*it % 2 == 0) {
it = numbers.erase(it);
} else {
++it;
}
}
Одна из частых задач — упорядочить вектор по возрастанию.
Для этого можно воспользоваться стандартной функцией sort()
:
#include <algorithm>
// ...
sort(numbers.begin(), numbers.end());
// здесь numbers уже отсортирован