diff --git a/README.md b/README.md index 5535491..d29830e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 1. [Основы языка C++](lectures/lecture01) 2. [Системы контроля версий](lectures/lecture02) -3. Структурирование кода и данных (функции, указатели) +3. [Структурирование кода и данных (функции, указатели)](lectures/lecture03) 4. Сборка программ из нескольких файлов 5. Ввод-вывод, модульное тестирование 6. Библиотеки diff --git a/lectures/lecture03/README.md b/lectures/lecture03/README.md new file mode 100644 index 0000000..98939da --- /dev/null +++ b/lectures/lecture03/README.md @@ -0,0 +1,554 @@ +# Лекция 3. Функции, указатели, ссылки, структуры + +## Функции (повторение) + +Функция — это именованный блок кода с формальными параметрами +и возвращаемым значением. + +Пример: + +```cpp +double +multiply(double x, int y) { + return x * y; +} +``` + +Здесь `double` — тип возвращаемого значения (результата), +`multiply` — имя функции, +`(double x, int y)` — список формальных параметров, +а в фигурных скобках — тело функции. + +Если вместо типа возвращаемого значения указать `void`, +это значит, что функция значения не возвращает. + +Тип возвращаемого значения, имя функции и список формальных параметров +могут быть разнесены по строкам в соответствии со стилем и читаемостью. +Например, мы в лабораторных работах предпочитаем тип возвращаемого значения +писать на отдельной строке. +С точки зрения C++ это безразлично. + +Типы ее формальных параметров составляют *сигнатуру* функции. +Отметим, что тип возвращаемого значения, имя функции и имена параметров +в сигнатуру не входят. +Например, сигнатура `multiply`: `(double, int)`. + +Оператор `return` определяет возвращаемое значение +и немедленно завершает функцию. +В `void`-функциях тоже можно использовать `return` без указания результата, +чтобы немедленно выйти из функции. + +**Внимание.** +Выполнение не-`void` функции всегда должно заканчиваться `return`, +хотя C++ не отслеживает это жестко (но может выдать предупреждение). + +Внутри тела функции её формальные параметры являются локальными переменными. +Они независимы от переменных в месте вызова функции, +даже если у них одинаковые имена: + +```cpp +void func(int x) { + x = 66; +} +... +int x = 42; +func(x); +// x == 42 +``` + +При вызове функции значения, переданные ей в качестве аргументов, +*копируются* в её переменные-параметры. + +## Указатели (повторение) + +**Примечание.** +В этой лекции не рассматривается динамическое выделение памяти, +а только сами указатели как тип данных и их применение в связи с функциями. + +Всю память компьютера можно представить как массив байтов. +Тогда индекс в этом массиве, то есть номер ячейки памяти, +называется *адресом,* а переменная, содержащая адрес, называется *указателем.* + +При объявлении указателей перед именем переменной ставится звездочка. +Например, так объявляется указатель на действительное число: + +```cpp +double* r1; +``` + +Часто звездочку прижимают к имени типа, а не переменной, как в примере. +Есть известная «ловушка»: + +```cpp +double* x, y; +``` + +Здесь только `x` является указателем (имеет тип `double*`), +а `y` является обычной переменной (имеет тип `double`). +Надо либо ставить звездочку перед каждой переменной-указателем, +либо объявлять каждую переменную отдельно (это почти всегда нагляднее). + +В указатель записывается не значение переменной, а ее адрес. +Адрес берется *оператором взятия адреса* в виде амперсанда (`&`): + +```cpp +double x = 3.14; +double* p = &x; +``` + +Вот как расположены при этом данные в памяти: + +``` +адреса: 0 1 8 9 10 11 12 42 43 44 45 46 + +---+- -+---+---+---+---+- -+---+---+---+---+- +ячейки: | | ..... | 3.14 | ..... | 8 | ... + +---+- -+---+---+---+---+- -+---+-.'+---+---+- + ↑\_____________/ \__.'_________/ + | x .' p + | .' + &x = 8 = ...............' +``` + +Чтобы, имея указатель, обратиться к тем данным, адрес которых он хранит, +используется оператор *разыменования* в виде звездочки: + +```cpp +*p = 2.71; // x = 2.71 +``` + +Есть специальное значение указателя — нулевой: `NULL`, `0` или `nullptr`. +Указатель, хранящий такой адрес, запрещено разыменовывать. + +Начальное значение указателя, если оно не присвоено явно, не определено, +как и для любых других переменных встроенных типов. +Таким образом, переменной-указателем нельзя корректно пользоваться, +пока ей что-нибудь не присвоено. + +### Висячие указатели (dangling pointers) + +К сожалению, C++ не отслеживает, что значение указателя всегда корректно. +Рассмотрим пример: + +```cpp +int* p = nullptr; +if (...) { + int x; + p = &x; + ... +} +cout << *p; +``` + +В последней строке `p` указывает на переменную, которая объявлена внутри `if` +и уже не существует после выхода из фигурных скобок. +Поэтому, хотя указатель и хранит не `nullptr`, разыменовывать его нельзя. +Такие указатели на данные, которых уже нет, называются *висячими (dangling).* + +Другой пример: + +```cpp +int* func() { + int x = 42; + return &x; +} +... +auto p = func(); +cout << *p; +``` + +Здесь функция возвращает адрес локальной переменной. +Однако локальные время жизни локальных переменных ограничено функцией, +поэтому пользоваться таким возвращаемым значением нельзя. + +При работе с указателями надо всегда думать о том, +чтобы время жизни указателя не превышало время жизни данных, +адрес которых указатели хранят. + +## Ссылки + +Ссылка (reference) — это новое имя для существующего объекта. +Объект может быть переменной или её частью, такой как элемент массива. + +Ссылки объявляются с использованием амперсанда: + +```cpp +int var = 42; +int& ref = var; +``` + +Не следует путать амперсанд при объявлении ссылок +с амперсандом-оператором взятия адреса! + +Обращение к ссылке эквивалентно обращению к тому, на что она ссылается: + +```cpp +cout << ref; // 42 +ref = 66; +cout << var; // 66 +``` + +В частности, так как ссылка не является самостоятельной переменной, +её адрес — это адрес того, на что она ссылается, +а явное разыменование не нужно (если это не ссылка на указатель, конечно): + +```cpp +if (&var == &ref) { ... } // истинно + +*ref = 66; // ОШИБКА: обращение к ref — обращение к var, + // а var не указатель, разыменовать её нельзя. +``` + +Поскольку ссылка — новое имя для *существующего* объекта, +у нее всегда должно быть начальное значение: + +```cpp +int& ref; // ОШИБКА: новое имя для чего? +``` + +Не бывает «нулевой ссылки», подобной нулевому указателю. + +Даже вне связи с функциями ссылки могут применяться, +чтобы дать более короткие или понятные имена в коде: + +```cpp +double& first = some_vector[0]; +// ... +fisrt = 0; +``` + +### Передача входных параметров функций по ссылкам + +Рассмотрим функцию, суммирующую элементы вектора: + +```cpp +double sum(vector xs) { + double total{}; + for (double x : xs) { + total += x; + } + return total; +} +``` + +Вспомним, что при вызове функции значения аргументов *копируются* +в переменные-формальные параметры, то есть в `xs` будет помещена копия вектора, +который передан функции. +Если этот вектор большой, будет потрачено много лишней памяти, +кроме того, это копирование бесполезно — функция не меняет `xs` даже внутри. + +Можно передавать `xs` по ссылке, чтобы не копировать вектор, +а работать с той переменной, которую передали функции, напрямую: + +```cpp +double sum(vector& xs) { ... } +... +vector xs; +double s = sum(xs); +``` + +Однако есть две проблемы: + +* Нельзя вызвать `sum({1, 2, 3})`, + потому что `{1, 2, 3}` — выражение, а не переменная. + Это запрещено, потому что с помощью ссылки возможно поменять то, + на что она ссылается, однако выражение поменять нельзя в принципе. + (Можно изменить значение переменной, содержащей `5 = 3 + 2`, + но нельзя поменять саму `5`, «пятерку как таковую».) + +* При чтении кода непонятно, не меняет ли `sum()` свой аргумент, + и нет гарантий, что она этого не начнет делать в будущем. + +Итак, нужно сослаться на аргумент, но сделать этот так, +чтобы с точки зрения `sum()` эта переменная была неизменяемой, +даже если в месте вызова менять ее можно. +Это делается с помощью константной ссылки: + +```cpp +double sum(const vector& xs) { ... } +``` + +При передаче параметров нетривиального типа (не `int`, `double` и т. п.), +в том числе при передаче `std::vector` и `std::string`, +рекомендуется по умолчанию использовать константную ссылку. + +## Выходные параметры функций через указатели и ссылки + +Составим функцию для решения квадратного уравнения в действительных числах. +Очевидно, что она принимает коэффициенты уравнения. +Возвращает она три значения: +* признак, что действительные решения есть; +* корень `x1`; +* корень `x2`. + +Однако у функции возвращаемое значение только одно, допустим, признак. +Как вернуть корни? + +Можно сделать это через ссылки: + +```cpp +bool solve(double a, double b, double c, double& x1, double& x2) { + auto d = b*b - 4*a*c; + if (d < 0) { + return false; + } + x1 = (-b + sqrt(d)) / 2*a; + x2 = (-b - sqrt(d)) / 2*a; + return true; +} +``` + +Вызов функции будет выглядеть так: + +```cpp +double x1, x2; +if (solve(3, 2, 1, x1, x2)) { + cout << "x1 = " << x1 << "\n" + << "x2 = " << x2 << "\n"; +} else { + cout << "Нет действительных корней.\n"; +} +``` + +Можно было бы использовать указатели: + +```cpp +bool solve(double a, double b, double c, double* x1, double* x2) { + auto d = b*b - 4*a*c; + if (d < 0) { + return false; + } + *x1 = (-b + sqrt(d)) / 2*a; + *x2 = (-b - sqrt(d)) / 2*a; + return true; +} +``` + +Вызов функции будет выглядеть так: + +```cpp +double x1, x2; +if (solve(3, 2, 1, &x1, &x2)) { + cout << "x1 = " << x1 << "\n" + << "x2 = " << x2 << "\n"; +} else { + cout << "Нет действительных корней.\n"; +} +``` + +Какой вариант лучше и почему? + +В случае с указателями в функцию мог бы быть передан нулевой указатель: + +```cpp +solve(3, 2, 1, nullptr, &x2); +``` + +Программа успешно компилировалась бы, но при запуске аварийно завершилась, +поскольку в функции `solve()` был бы разыменован нулевой указатель `x1`. + +Может показаться, что из-за этого вариант с указателями хуже: +функция должна проверять, что ей не передали нулевой указатель, +а со ссылками этого не потребовалось бы — ведь «нулевых ссылок» нет. +Однако наличие особого значения у указателя — не только проблема, +но и возможность связать с этим значением особую логику. +Например, функция могла бы быть реализована так: + +```cpp +bool solve(double a, double b, double c, double* x1, double* x2) { + auto d = b*b - 4*a*c; + if (d < 0) { + return false; + } + if (x1) { + *x1 = (-b + sqrt(d)) / 2*a; + } + if (x2) { + *x2 = (-b - sqrt(d)) / 2*a; + } + return true; +} +``` + +Теперь, если передать в функцию `nullptr` в качестве `x1` или `x2`, +она не будет вычислять соответствующий корень. +Таким образом программа может сэкономить вычисления, +если оба корня ей заведомо не нужны. +Можно даже передать `nullptr` в качестве и `x1`, и `x2`, +тогда функция просто проверит, есть ли действительные решения — +возможно, конкретной программе, которая использует `solve()`, +только это и нужно. + +Вывод: использовать для передачи выходных параметров указатели или ссылки +зависит от того, нужна ли дополнительная гибкость логики, +которую дает наличие особого значения — нулевого указателя. + +Заметим, что некоторые проекты предпочитают всегда использовать указатели, +даже если предполагается, что они обязаны не быть `nullptr` никогда. +Причина в том, что в случае ссылок по вызову `solve(a, b, c, x1, x2)` +невозможно определить, какие из переменных после этой строки могут поменяться. +Вызов же `solve(a, b, c, &x1, &x2)` ясно показывает, +что `solve()` может поменять `x1` и `x2`. + +## Структуры + +Структура — это пользовательский тип данных, +представляющий собой совокупность именованных полей различных типов. + +Структуры удобны для того, чтобы сгруппировать несколько переменных, +которые используются в программе совместно. +Например, в задаче ЛР № 1 можно было бы объединить входные данные в структуру: + +```cpp +struct Input { + std::vector numbers; + size_t bin_count; +}; +``` + +Здесь `Input` — имя структуры, а `numbers` и `bin_count` — её поля. + +Важно понять, что определение выше описывает тип данных (аналог `std::vector` +или `std::string`), а не переменную, то есть код выше не описывает переменные +`numbers` и `bin_count`, куда можно сохранить значения. Он описывает, +что каждая переменная типа `Input` содержит поля `numbers` и `bin_count`, +в которые уже можно сохранить конкретные данные. + +Переменные типа структур объявляются так же, как переменные других типов; +именем типа выступает имя структуры: + +```cpp +Input x; +``` + +Говорят, что переменная `x` — экземпляр структуры. + +К полям структуры обращаются через точку: + +```cpp +cout << x.numbers.size(); // 0 +cin >> x.bin_count; +if (x.bin_count == 0) { ... } +``` + +Можно объявить несколько переменных типа структуры: + +```cpp +Input y; +Input z; + +y.bin_count = 3; +z.bin_count = 4; +``` + +Значения, хранимые в `x`, `y` и `z` будут независимы друг от друга. + +### Инициализация полей + +Вернемся к объявлению `x`, чему было равно `x.bin_count` до её ввода? +Так как в определении структуры +для поля `bin_count` не было указано значения по умолчанию, +то и конкретное начальное значение `x.bin_count` не определено. + +Удобно, чтобы у всех полей были начальные значения. +Например, так можно инициализировать нулем `bin_count` +любой новой переменной типа `Input`: + +```cpp +struct Input { + vector numbers; + size_t bin_count{}; // или size_t bin_count = 0; +}; +``` + +Как известно, переменные типа `vector` по умолчанию содержат пустой вектор, +поэтому для поля `numbers` о начальном значении заботиться не нужно. + +### Оператор «стрелка» (`->`) + +На переменные типа структуры могут быть указатели: + +```cpp +Input* p = new Input; +``` + +Чтобы использовать оператор `.` для доступа к полям структуры, +на которую указывает `p`, нужно сначала разыменовать `p`. +Оператор `.` имеет наивысший приоритет, поэтому нужны скобки: `(*p).bin_count`. +Это громоздко, поэтому в C++ введено оператор «стрелки» `->`, +чтобы записать то же самое проще: `p->bin_count`. + +## Перегрузка функций + +Можно объявить набор функций с одинаковыми именами, но разными сигнатурами: + +```cpp +// print(3.14) -> 3.14 +void print(double x) { + cout << x; +} + +// print({1, 2, 3}) -> {1, 2, 3}; +void print(const std::vector& xs) { + cout << "{"; + bool need_comma = false; + for (auto x : xs) { + if (need_comma) { + cout << ", "; + } else { + need_comma = true; + } + print(x); // вызов print(double), а не рекурсия + } + cout << "}"; +} +``` + +Говорят, что функция `print()` *перегружена (overloaded),* +а каждая из конкретных функций называется её *перегрузкой.* +Когда компилятор встречает вызов `print()`, он анализирует типы параметров +и вызывает соответствующую перегрузку. + +Перегрузки бывают полезны в обобщенном коде, +то есть таком, который готов работать с различными типами данных. +Об это будет рассказано на последующих лекциях. + +## Объявление и определение функции + +Будет ли компилироваться программа такого вида? + +```cpp +void foo() { + bar(); +} + +void bar() { + foo(); +} +``` + +Функция `foo()` вызывает функцию `bar()` раньше, чем она описана, +что вызовет ошибку. +Однако перенести описание `bar()` выше описания `foo()` нельзя: +`bar()` содержит вызов `foo()`, который тогда окажется выше, +чем описана `foo()`. + +Рассуждая логически, для того, чтобы скомпилировать `foo()`, +компилятору не нужно тело функции `bar()` — достаточно знать, +что такая функция есть, и какие у нее параметры (нет параметров). +Сообщить компилятору о сигнатуре функции можно с помощью +*объявления функции (declaration)*. +В отличие от уже знакомого *определения функции (definition),* +объявление не содержит тела, а кончается точкой с запятой: + +```cpp +void bar(); // объявление bar() + +void foo() { + bar(); // вызов bar() +} + +void bar() { // определение bar() + foo(); +} +``` +Объявления и определения важны при делении программы на файлы.