|
2 лет назад | |
---|---|---|
.. | ||
README.md | 2 лет назад |
README.md
Лекция 3. Функции, указатели, ссылки, структуры
Функции (повторение)
Функция — это именованный блок кода с формальными параметрами и возвращаемым значением.
Пример:
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++ не отслеживает это жестко (но может выдать предупреждение).
Внутри тела функции её формальные параметры являются локальными переменными. Они независимы от переменных в месте вызова функции, даже если у них одинаковые имена:
void func(int x) {
x = 66;
}
...
int x = 42;
func(x);
// x == 42
При вызове функции значения, переданные ей в качестве аргументов, копируются в её переменные-параметры.
Указатели (повторение)
Примечание. В этой лекции не рассматривается динамическое выделение памяти, а только сами указатели как тип данных и их применение в связи с функциями.
Всю память компьютера можно представить как массив байтов. Тогда индекс в этом массиве, то есть номер ячейки памяти, называется адресом, а переменная, содержащая адрес, называется указателем.
При объявлении указателей перед именем переменной ставится звездочка. Например, так объявляется указатель на действительное число:
double* r1;
Часто звездочку прижимают к имени типа, а не переменной, как в примере. Есть известная «ловушка»:
double* x, y;
Здесь только x
является указателем (имеет тип double*
),
а y
является обычной переменной (имеет тип double
).
Надо либо ставить звездочку перед каждой переменной-указателем,
либо объявлять каждую переменную отдельно (это почти всегда нагляднее).
В указатель записывается не значение переменной, а ее адрес.
Адрес берется оператором взятия адреса в виде амперсанда (&
):
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 = ...............'
Чтобы, имея указатель, обратиться к тем данным, адрес которых он хранит, используется оператор разыменования в виде звездочки:
*p = 2.71; // x = 2.71
Есть специальное значение указателя — нулевой: NULL
, 0
или nullptr
.
Указатель, хранящий такой адрес, запрещено разыменовывать.
Начальное значение указателя, если оно не присвоено явно, не определено, как и для любых других переменных встроенных типов. Таким образом, переменной-указателем нельзя корректно пользоваться, пока ей что-нибудь не присвоено.
Висячие указатели (dangling pointers)
К сожалению, C++ не отслеживает, что значение указателя всегда корректно. Рассмотрим пример:
int* p = nullptr;
if (...) {
int x;
p = &x;
...
}
cout << *p;
В последней строке p
указывает на переменную, которая объявлена внутри if
и уже не существует после выхода из фигурных скобок.
Поэтому, хотя указатель и хранит не nullptr
, разыменовывать его нельзя.
Такие указатели на данные, которых уже нет, называются висячими (dangling).
Другой пример:
int* func() {
int x = 42;
return &x;
}
...
auto p = func();
cout << *p;
Здесь функция возвращает адрес локальной переменной. Однако локальные время жизни локальных переменных ограничено функцией, поэтому пользоваться таким возвращаемым значением нельзя.
При работе с указателями надо всегда думать о том, чтобы время жизни указателя не превышало время жизни данных, адрес которых указатели хранят.
Ссылки
Ссылка (reference) — это новое имя для существующего объекта. Объект может быть переменной или её частью, такой как элемент массива.
Ссылки объявляются с использованием амперсанда:
int var = 42;
int& ref = var;
Не следует путать амперсанд при объявлении ссылок с амперсандом-оператором взятия адреса!
Обращение к ссылке эквивалентно обращению к тому, на что она ссылается:
cout << ref; // 42
ref = 66;
cout << var; // 66
В частности, так как ссылка не является самостоятельной переменной, её адрес — это адрес того, на что она ссылается, а явное разыменование не нужно (если это не ссылка на указатель, конечно):
if (&var == &ref) { ... } // истинно
*ref = 66; // ОШИБКА: обращение к ref — обращение к var,
// а var не указатель, разыменовать её нельзя.
Поскольку ссылка — новое имя для существующего объекта, у нее всегда должно быть начальное значение:
int& ref; // ОШИБКА: новое имя для чего?
Не бывает «нулевой ссылки», подобной нулевому указателю.
Даже вне связи с функциями ссылки могут применяться, чтобы дать более короткие или понятные имена в коде:
double& first = some_vector[0];
// ...
fisrt = 0;
Передача входных параметров функций по ссылкам
Рассмотрим функцию, суммирующую элементы вектора:
double sum(vector<double> xs) {
double total{};
for (double x : xs) {
total += x;
}
return total;
}
Вспомним, что при вызове функции значения аргументов копируются
в переменные-формальные параметры, то есть в xs
будет помещена копия вектора,
который передан функции.
Если этот вектор большой, будет потрачено много лишней памяти,
кроме того, это копирование бесполезно — функция не меняет xs
даже внутри.
Можно передавать xs
по ссылке, чтобы не копировать вектор,
а работать с той переменной, которую передали функции, напрямую:
double sum(vector<double>& xs) { ... }
...
vector<double> xs;
double s = sum(xs);
Однако есть две проблемы:
-
Нельзя вызвать
sum({1, 2, 3})
, потому что{1, 2, 3}
— выражение, а не переменная. Это запрещено, потому что с помощью ссылки возможно поменять то, на что она ссылается, однако выражение поменять нельзя в принципе. (Можно изменить значение переменной, содержащей5 = 3 + 2
, но нельзя поменять саму5
, «пятерку как таковую».) -
При чтении кода непонятно, не меняет ли
sum()
свой аргумент, и нет гарантий, что она этого не начнет делать в будущем.
Итак, нужно сослаться на аргумент, но сделать этот так,
чтобы с точки зрения sum()
эта переменная была неизменяемой,
даже если в месте вызова менять ее можно.
Это делается с помощью константной ссылки:
double sum(const vector<double>& xs) { ... }
При передаче параметров нетривиального типа (не int
, double
и т. п.),
в том числе при передаче std::vector<T>
и std::string
,
рекомендуется по умолчанию использовать константную ссылку.
Выходные параметры функций через указатели и ссылки
Составим функцию для решения квадратного уравнения в действительных числах. Очевидно, что она принимает коэффициенты уравнения. Возвращает она три значения:
- признак, что действительные решения есть;
- корень
x1
; - корень
x2
.
Однако у функции возвращаемое значение только одно, допустим, признак. Как вернуть корни?
Можно сделать это через ссылки:
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;
}
Вызов функции будет выглядеть так:
double x1, x2;
if (solve(3, 2, 1, x1, x2)) {
cout << "x1 = " << x1 << "\n"
<< "x2 = " << x2 << "\n";
} else {
cout << "Нет действительных корней.\n";
}
Можно было бы использовать указатели:
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;
}
Вызов функции будет выглядеть так:
double x1, x2;
if (solve(3, 2, 1, &x1, &x2)) {
cout << "x1 = " << x1 << "\n"
<< "x2 = " << x2 << "\n";
} else {
cout << "Нет действительных корней.\n";
}
Какой вариант лучше и почему?
В случае с указателями в функцию мог бы быть передан нулевой указатель:
solve(3, 2, 1, nullptr, &x2);
Программа успешно компилировалась бы, но при запуске аварийно завершилась,
поскольку в функции solve()
был бы разыменован нулевой указатель x1
.
Может показаться, что из-за этого вариант с указателями хуже: функция должна проверять, что ей не передали нулевой указатель, а со ссылками этого не потребовалось бы — ведь «нулевых ссылок» нет. Однако наличие особого значения у указателя — не только проблема, но и возможность связать с этим значением особую логику. Например, функция могла бы быть реализована так:
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 можно было бы объединить входные данные в структуру:
struct Input {
std::vector<double> numbers;
size_t bin_count;
};
Здесь Input
— имя структуры, а numbers
и bin_count
— её поля.
Важно понять, что определение выше описывает тип данных (аналог std::vector
или std::string
), а не переменную, то есть код выше не описывает переменные
numbers
и bin_count
, куда можно сохранить значения. Он описывает,
что каждая переменная типа Input
содержит поля numbers
и bin_count
,
в которые уже можно сохранить конкретные данные.
Переменные типа структур объявляются так же, как переменные других типов; именем типа выступает имя структуры:
Input x;
Говорят, что переменная x
— экземпляр структуры.
К полям структуры обращаются через точку:
cout << x.numbers.size(); // 0
cin >> x.bin_count;
if (x.bin_count == 0) { ... }
Можно объявить несколько переменных типа структуры:
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
:
struct Input {
vector<double> numbers;
size_t bin_count{}; // или size_t bin_count = 0;
};
Как известно, переменные типа vector<T>
по умолчанию содержат пустой вектор,
поэтому для поля numbers
о начальном значении заботиться не нужно.
Оператор «стрелка» (->
)
На переменные типа структуры могут быть указатели:
Input* p = new Input;
Чтобы использовать оператор .
для доступа к полям структуры,
на которую указывает p
, нужно сначала разыменовать p
.
Оператор .
имеет наивысший приоритет, поэтому нужны скобки: (*p).bin_count
.
Это громоздко, поэтому в C++ введено оператор «стрелки» ->
,
чтобы записать то же самое проще: p->bin_count
.
Перегрузка функций
Можно объявить набор функций с одинаковыми именами, но разными сигнатурами:
// print(3.14) -> 3.14
void print(double x) {
cout << x;
}
// print({1, 2, 3}) -> {1, 2, 3};
void print(const std::vector<double>& 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()
, он анализирует типы параметров
и вызывает соответствующую перегрузку.
Перегрузки бывают полезны в обобщенном коде, то есть таком, который готов работать с различными типами данных. Об это будет рассказано на последующих лекциях.
Объявление и определение функции
Будет ли компилироваться программа такого вида?
void foo() {
bar();
}
void bar() {
foo();
}
Функция foo()
вызывает функцию bar()
раньше, чем она описана,
что вызовет ошибку.
Однако перенести описание bar()
выше описания foo()
нельзя:
bar()
содержит вызов foo()
, который тогда окажется выше,
чем описана foo()
.
Рассуждая логически, для того, чтобы скомпилировать foo()
,
компилятору не нужно тело функции bar()
— достаточно знать,
что такая функция есть, и какие у нее параметры (нет параметров).
Сообщить компилятору о сигнатуре функции можно с помощью
объявления функции (declaration).
В отличие от уже знакомого определения функции (definition),
объявление не содержит тела, а кончается точкой с запятой:
void bar(); // объявление bar()
void foo() {
bar(); // вызов bar()
}
void bar() { // определение bar()
foo();
}
Объявления и определения важны при делении программы на файлы.