Функции позволяют отделить часто используемый код и переиспользовать его с разными значениями аргументов. С примером функции мы уже знакомы: в каждой программе вы пишете функцию main
, которая не принимает аргументов и возвращает int
.
Примеры функций
Напишем простейшую функцию, вычисляющую сумму двух целых чисел:
1int Sum(int a, int b) { // в заголовке функции указывается тип возвращаемого значения и типы аргументов
2 return a + b;
3}
Если функция ничего не должна возвращать, её можно объявить как void
:
1void DoSomething(double d, char c) {
2 // ...
3 // писать return в конце такой функции не обязательно,
4 // но если требуется завершить функцию, можно написать просто return;
5}
6
7int main() {
8 int x = 17, y = 42;
9 int z = Sum(x, y);
10 DoSomething(3.14, '@');
11}
Вот пример рекурсивной функции, вычисляющей факториал:
1#include <cstdint>
2#include <iostream>
3
4std::uint64_t Factorial(std::uint64_t n) {
5 if (n == 0) {
6 return 1;
7 }
8 return n * Factorial(n - 1); // рекурсивный вызов
9}
10
11int main() {
12 std::cout << Factorial(5) << "\n"; // 120
13}
Помните, что если делать очень много рекурсивных вызовов, то рано или поздно переполнится стек — область памяти, в которой хранятся аргументы и локальные переменные текущей функции.
Аргументы функций
Параметры в функции по умолчанию передаются «по значению». Другими словами, функция работает с копиями аргументов. Чтобы лучше представить это, давайте посмотрим, что бы получилось, если бы компилятор заменил вызов функции на непосредственное исполнение кода.
Возьмём такой фрагмент кода:
1void f(int x, int y) {
2 // работаем с аргументами x и y
3}
4
5int main() {
6 int a, b;
7 // какая-то инициализация a и b
8
9 f(a, b);
10}
Заменим его на такой код:
1int main() {
2 int a, b;
3 // какая-то инициализация a и b
4
5 { // этот блок просто ограничивает время жизни
6 // находящихся внутри переменных
7 int x = a;
8 int y = b;
9 // работаем с аргументами x и y
10 }
11}
Теперь видно, что любое изменение x
или y
внутри функции никак не затронет a
и b
.
Можем ли мы изменить переданный аргумент внутри функции, чтобы это повлияло на аргументы в месте вызова? Да, для этого надо передать аргументы через ссылку или указатель. Вот классический пример функции, меняющей два аргумента местами:
1void Swap(int& x, int& y) { // передаём аргументы по ссылке
2 int z = x;
3 x = y;
4 y = z;
5}
6
7int main() {
8 int a = 1, b = 2;
9 Swap(a, b);
10 std::cout << a << " " << b << "\n"; // 2 1
11}
Чтобы понять, как это работает, раскроем снова код функции в месте вызова:
1int main() {
2 int a = 1, b = 2;
3
4 {
5 int& x = a;
6 int& y = b;
7 int z = x;
8 x = y;
9 y = z;
10 }
11
12 std::cout << a << " " << b << "\n"; // 2 1
13}
Видно, что x
и y
— это просто псевдонимы для a
и b
.
Заметьте, что вызов Swap(1, 2)
, в отличие от Swap(a, b)
, не скомпилируется, потому что обычная ссылка должна быть привязана к изменяемому объекту.
Примером функции из стандартной библиотеки, которая принимает аргумент по ссылке и изменяет его, является std::getline
:
1#include <iostream>
2#include <string>
3
4int main() {
5 std::string line;
6
7 // Второй аргумент передаётся по ссылке и изменяется внутри функции:
8 std::getline(std::cin, line);
9}
Иногда копирование объекта может быть очень дорогим (и ненужным). Например, копирование вектора приведёт к копированию всех его элементов. Поэтому вот так передавать вектор в функцию неэффективно:
1void f(std::vector<int> v) {
2 // плохо: при вызове функции создаётся копия вектора
3}
Копии можно было бы избежать, если бы вектор передавался по ссылке:
1void f(std::vector<int>& v) {
2 // Но теперь есть другие недостатки:
3 // 1. В такую функцию нельзя передать константный вектор.
4 // 2. Функция не защищена от случайного изменения вектора:
5 v.clear(); // тут компилятор нас не схватит за руку
6}
Поэтому самое правильное — передавать такой параметр по константной ссылке:
1void f(const std::vector<int>& v) {
2 // Такой аргумент не требует дорогого копирования,
3 // его нельзя случайно изменить внутри,
4 // и такую функцию можно вызывать от констант!
5}
Давайте запомним: аргументы сложных типов (векторы, строки, любые контейнеры, большие структуры) всегда лучше передавать в функцию по константной ссылке, если функция использует их только для чтения. Из этого правила бывают исключения, но о них мы поговорим отдельно.
Впрочем, это правило не стоит распространять на обычные встроенные типы:
1void g(const int& a, const char& c) { // так делать не надо, это уже перебор!
2 // передавайте такие параметры просто по значению, как int или char
3}
Возвращаемые значения функций
В отличие от аргументов, значения сложных типов можно без проблем возвращать из функций. Здесь от ненужного копирования (по крайней мере, для стандартных контейнеров) спасает copy elision.
Рассмотрим, например, функцию, которая возвращает конкатенацию всех строк из вектора:
1#include <iostream>
2#include <string>
3#include <vector>
4
5std::string Concatenate(const std::vector<std::string>& parts) {
6 std::string result;
7 for (const auto& part : parts) {
8 result += part;
9 }
10 return result;
11}
12
13int main() {
14 std::vector<std::string> parts = {"abra", "ca", "dabra"};
15 std::cout << Concatenate(parts) << "\n"; // abracadabra
16}
Опасно возвращать из функции ссылку на локальную переменную, так как эта ссылка сразу же станет «висячей»:
1#include <iostream>
2
3int& Sum(int a, int b) { // ошибка!
4 int result = a + b;
5 return result;
6}
7
8int main() {
9 std::cout << Sum(2, 3) << "\n"; // неопределённое поведение!
10}
Компиляторы в таких случаях генерируют предупреждения.
Возвращать значение по ссылке можно только в случае, если оно заведомо будет доступно после завершения функции. Например, так можно вернуть глобальную переменную или аргумент, также переданный по ссылке.
Функции-компараторы
Пусть имеется структура Date
, описывающая день, месяц и год какой-то даты. Создадим вектор дат:
1#include <algorithm>
2#include <iostream>
3#include <vector>
4
5struct Date {
6 int year = 1970;
7 int month = 1;
8 int day = 1;
9};
10
11int main() {
12 std::vector<Date> dates = {
13 {2020, 3, 15},
14 {2019, 1, 21},
15 {2021, 1, 30}
16 };
17
18 // напечатаем содержимое:
19 for (const auto& [year, month, day] : dates) {
20 std::cout << year << "." << month << "." << day << "\n";
21 }
22}
Предположим, нам требуется отсортировать даты. Для сортировки нам поможет уже знакомая функция std::sort
, но есть нюанс: вызов std::sort(dates.begin(), dates.end())
не скомпилируется, так как компилятор не умеет сравнивать даты между собой. Функция std::sort
пытается найти оператор <
для сравнения дат, но, увы, для нашей даты такого нет. Мы можем его определить. Он выглядит как функция с особым именем operator <
, возвращающая true
, если первый аргумент меньше второго:
1bool operator < (const Date& lhs, const Date& rhs) {
2 if (lhs.year != rhs.year) {
3 return lhs.year < rhs.year;
4 }
5 if (lhs.month != rhs.month) {
6 return lhs.month < rhs.month;
7 }
8 return lhs.day < rhs.day;
9}
Здесь lhs
и rhs
— сокращения от left-hand side и right-hand side. Это левый и правый аргументы оператора <
. Этот громоздкий код можно записать лаконичнее с использованием функции std::tie
, возвращающей кортеж из ссылок, для которого уже определено лексикографическое (покомпонентное) сравнение:
1bool operator < (const Date& lhs, const Date& rhs) {
2 return std::tie(lhs.year, lhs.month, lhs.day) < std::tie(rhs.year, rhs.month, rhs.day);
3}
После определения operator <
сортировка заработает. Но что, если нам в разных случаях нужно по-разному сортировать даты — например, где-то в хронологическом порядке, а где-то — без учёта года? Можно передать в std::sort
третьим аргументом свою функцию сравнения, которая будет использована вместо operator <
:
1bool CompareWithoutYear(const Date& lhs, const Date& rhs) {
2 return std::tie(lhs.month, lhs.day) < std::tie(rhs.month, rhs.day);
3}
4
5int main() {
6 // ...
7 std::sort(dates.begin(), dates.end(), CompareWithoutYear);
8}
Обратите внимание, что третьим аргументом в std::sort
мы передаём саму функцию (без круглых скобок), а не результат её вызова от каких-то аргументов.
Лямбда-функции
Иногда бывает неудобно определять отдельную именованную функцию для сравнения. Тогда можно определить анонимную лямбда-функцию прямо в месте её использования:
1#include <algorithm>
2#include <vector>
3
4struct Date {
5 int year, month, day;
6};
7
8int main() {
9 std::vector<Date> dates;
10 std::sort(dates.begin(), dates.end(), [](const Date& lhs, const Date& rhs) {
11 return std::tie(lhs.month, lhs.day) < std::tie(rhs.month, rhs.day);
12 });
13}
Тип возвращаемого значения тут не указывается, компилятор умеет его угадывать по return
(его можно указать после круглых скобок на «питоновский» манер через ->
, но не обязательно).
Разберём синтаксис лямбда-функций. Тут видны три блока.
- Квадратные скобки отвечают за контекст. В них мы можем передать переменные, которые объявлены вне лямбда-функции через запятую, и они будут доступны в самой лямбда-функции.
- Круглые скобки отвечают за аргументы функции.
- Фигурные скобки отвечают за тело лямбда-функции.
Когда лямбды добавлялись в стандарт C++11, разработчики очень не хотели вводить для них новое ключевое слово (как lambda
в Python) и обошлись комбинацией скобок. Есть шутка про то, что вот такая программа является вполне корректной:
1int main() {[](){}();}
Попробуйте разобраться, что тут происходит.