Функция - блок кода, который можно вызвать по его имени из другого блока кода или при инициализации переменной. Функция может возвращать значение в зависимости от переданных аргументов и своего внутреннего "устройства". Пример из математики - функция синус: возвращает значение координаты на оси в зависимости от переданного ей угла.
На низком уровне это работает примерно так: весь код хранится в постоянной памяти списком, друг за другом, инструкция за инструкцией. Функция - по сути набор инструкций, который находится по известному адресу в памяти. Допустим выполняется какой-то код и доходит до инструкции "перейти по адресу". Процессор сохраняет адрес текущей инструкции в оперативной памяти и переходит по адресу функции. Выполняет её код, а затем возвращается обратно по адресу, который запомнил - адресу возврата.
Также можно представить это в виде оператора goto
, цифрами указан порядок выполнения:
// какой то код #1
goto my_func; // переход к функции #2
ret: // возвращаемся сюда #5
// какой то код #6
// ...
// Функция
my_func:
// код функции #3
goto ret; // возврат обратно #4
На уровне языка функции имеют более удобный синтаксис и больше возможностей.
Возврат значения #
В C/C++ функция всегда должна возвращать результат, т.е. вызов функции является каким то значением, которое можно использовать дальше в программе - данные этого типа подставятся вместо имени функции после её вызова и выполнения. Синтаксис следующий:
// создание
тип_данных имя_функции() {
// тело функции
return тип_данных;
}
// вызов
тип_данных переменная = имя_функции();
- Функции можно создавать только вне остальных функций и блоков кода - на самом верхнем уровне программы, как глобальные переменные
- Имена функций не должны совпадать с именами переменных и других сущностей
- Функция должна быть создана до того (выше по коду), как вызывается - как переменная
- Функцию можно вызвать только из другой функции или при инициализации
- Вызов функции является инструкцией и должен оканчиваться точкой с запятой
- Базовая функция, которая вызывается самостоятельно в начале программы -
int main()
- Функция должна вернуть данные своего типа при помощи оператора
return
- Системная функция
int main
является исключением и может ничего не возвращать - В функции типа
void
может отсутствоватьreturn
- Системная функция
Пример полной программы:
int foo() {
return 3;
}
int bar() {
// ошибка - нет возврата int
}
//foo(); // ошибка - функция вызвана вне исполняемого кода
int a = foo(); // так можно, a == 3
// начало программы
int main() {
foo(); // просто вызов, не получаем результат
int var = foo(); // пишем результат в переменную
// var == 3
// ошибка - нельзя создать функцию внутри функции
//int foobar() {
// return 1;
//}
}
Так как вызов функции - тоже инструкция, функцию можно вызвать и по ус ловию, и в цикле:
if (условие) foo();
while (true) foo();
Void функция #
Не всегда нужна функция, которая отправит какие-то данные, т.е. должна просто выполнить свой код. В других языках программирования такие функции называются процедурами - в C/C++ процедур нет, эту роль выполняют функции. Существует специальный тип данных void
- пустота, "ничего". С таким типом тоже можно сделать функцию:
void foo() {
//return; // - не обязательно
}
- У
void
функции может отсутствоватьreturn
- Если
return
есть - он не должен ничего возвращать, то есть простоreturn;
Оператор return #
Оператор return
завершает выполнение функции и возвращает результат работы, и может быть вызван в любом месте тела функции:
// правильно
void func1() {
// код
if (..) return;
// код
}
// правильно
int func2() {
// код
if (..) return 0;
// код
return 1:
}
// ошибка
int func3() {
return; // возвращён void, должен быть int
}
// ошибка
void func4() {
return 4; // возвращён int, должно быть просто return;
}
Объявление и определение #
В зависимости от настроек компилятора, функция может быть вызвана только ниже по коду программы от того места, где создана. А может и нет:
int main() {
foo(3); // ошибка, функция не найдена
}
void foo(int a) {
}
Создание функции может быть разделено на два этапа:
- Объявление (declaration) - указание компилятору, что существует функция с таким-то именем и набором параметров
- Определение (definition) - непосредственно исполняемый код функции
Объявление функции также называется её прототипом (prototype), а определение - реализацией (implementation)
// прототип
void foo(int a);
int main() {
foo(3);
}
// реализация
void foo(int a) {
}
Это нужно как в рамках одного файла программы, так и при разделении на файлы, о чём поговорим подробнее в следующем уроке.
Параметры и аргументы #
Параметры функции - переменные, которые принимает функция, позволяют создавать функции с целью "сделай мне что-то вот с этими данными". Параметры указываются в круглых скобках через запятую:
тип имя_функции(тип параметр1, тип параметр2) {
// тело функции
}
Совокупность возвращаемого типа, имени функции и набора параметров называется сигнатурой функции. В программе не может существовать нескольких функций с одной сигнатурой - только одна, иначе будет ошибка компиляции
При вызове функции мы должны передать ей аргументы - данные (значения, переменные), которые станут параметрами при вызове функции, т.е. запишутся в них как значения. Аргументы должны быть тех же типов и в том же порядке, что и параметры в функции:
имя_функции(аргумент1, аргумент2);
- Параметры являются локальными переменными и имеют область видимости только внутри тела своей функции
- После выхода из функции параметры автоматически удаляются из памяти
- Для вызова функции с параметрами нужно указать аргументы требуемых типов в том же порядке, в котором они указаны в самой функции
- При несовпадении типа аргумента он будет по возможности сконвертирован в нужный тип параметра
Пример - функция, которая складывает числа и возвращает результат:
int sum(int a, int b) {
return a + b;
}
int res = sum(5, 6);
// res == 11
Порядок вызова аргументов не определён стандартом, поэтому например в вызове func(foo(), bar())
вызов функций foo()
и bar()
может быть любым
Передача по значению #
Здесь важно понимать, как именно передаются аргументы. В примере выше мы передали их по значению, то есть в функцию попадает независимая копия. Например:
void foo(int param) {
param = 456; // меняется локальный параметр param!
}
int var = 123;
foo(var);
// var == 123
Передача по указателю/ссылке #
Что делать, если нужно поменять значение переданной переменной внутри функции? Передавать её по ссылке или по указателю:
// по ссылке
void foo(int& param) {
param = 456;
}
// по указателю
void bar(int* param) {
*param = 789;
}
int main() {
int var = 123;
foo(var);
// var == 456
bar(&var);
// var == 789
}
Ошибка в языке Си #
В языке C есть ошибка, допущенная во времена зарождения языка и оставленная для совместимости - в функцию без параметров можно передать сколько угодно аргументов и это не приведёт к ошибке компиляции:
void foo() {
}
foo(123, 456, 3.14, "строка"); // нет ошибки
Для исправления такого поведения в качестве параметров указывают void
- тогда нельзя будет вызвать эту функцию с параметрами:
void foo(void) {
}
foo(123, 456, 3.14, "строка"); // ошибка компиляции
В языке C++ этой ошибки нет, но поддержка (void)
оставлена для совместимости с Си, поэтому если в C++ программе вы увидите func(void)
- её автор не определился, на каком языке он пишет
Перегруженные функции (C++) #
В C++ появилась поддержка перегруженных функций - очень мощный инструмент для написания гибкого кода и создания библиотек. Перегруженная функция - это функция, у которой есть "аналоги" с таким же именем, но другим набором аргументов. Это позволяет объединить под одним именем набор функций с одинаковой логикой, но разной реализацией для разных наборов аргументов. Например функции для сложения:
int sum(int a, int b) { // #1
return a + b;
}
int sum(int a, int b, int c) { // #2
return a + b + c;
}
float sum(float a, float b) { // #3
return a + b;
}
float sum(float a, float b, float c) { // #4
return a + b + c;
}
int res1 = sum(3, 4); // вызовется #1
int res2 = sum(3, 4, 5); // вызовется #2
float res3 = sum(3.0, 4.0); // вызовется #3
float res4 = sum(3.0, 4.0, 5.0); // вызовется #4
void foo() {}
// ошибка, перегрузить ТОЛЬКО по типу возвращаемого значения нельзя
int foo() { return 0; }
Параметры по умолчанию #
В C++ добавилась возможность указывать параметры по умолчанию - значения, которыми они будут инициализированы, если не указать их явно. Таким образом функция становится по сути перегруженной самой себе:
// один обязательный параметр, остальные нет
void foo(int a, int b = 1, int c = 2) {
}
foo(123); // при вызове a == 123, b == 1, c == 2
foo(123, 456); // при вызове a == 123, b == 456, c == 2
foo(123, 456, 789); // при вызове a == 123, b == 456, c == 789
Если функция реализована отдельно от объявления, то параметры по умолчанию можно указывать только в прототипе, это также касается методов классов:
// прототип
void foo(int a, int b = 1, int c = 2);
// реализация
void foo(int a, int b, int c) {
}
Ambiguous #
Если компилятор не может однозначно выбрать из нескольких перегруженных функций конкретную - будет ошибка "ambiguous" (двусмысленный):
void foo() {} // #1
void foo(int a = 0) {} // #2
void foo(int a, float b) {} // #3
void foo(float a, int b) {} // #4
foo(123); // не ошибка, это #2
foo(); // ошибка, это #1 или #2
foo(12, 34.0); // не ошибка, это #3
foo(12, 34); // ошибка, это #3 или #4
Ещё хороший пример: есть набор перегруженных функций, которые принимают указатели на разные типы. А нам надо передать туда нулевой указатель nullptr
:
void func(int* ptr) {}
void func(float* ptr) {}
int i;
func(&i);
func(nullptr); // ошибка ambiguous!
Для решения пригодится тип nullptr_t
:
void func(int* ptr) {} // #1
void func(float* ptr) {} // #2
void func(nullptr_t ptr) {} // #3
int i;
func(&i); // вызов #1
func(nullptr); // вызов #3
Указатель на функцию #
Функция - тоже "данные", которые имеют адрес в памяти - на неё тоже можно сделать указатель и вызвать её по нему. Синтаксис указателей на функции считается самым сложным в C++ - можно встретить такие конструкции, что становится страшно. Но в общем случае всё довольно просто:
тип (*имя)(аргументы); // указатель с именем "имя" на функцию "тип(аргументы)"
void func1() {}
int func2(int a, int b) { return 0; }
// .....
void (*ptr1)();
ptr1 = func1;
ptr1(); // вызов
int (*ptr2)(int, int);
ptr2 = func2;
int res = ptr2(3, 4); // вызов
При разработке библиотек часто используют typedef
для таких указателей, чтобы создать свой тип указателя в одном месте и использовать его как тип данных - это более коротко и удобно:
// FuncPtr - тип данных "указатель на функцию типа void(int, int)"
typedef void (*FuncPtr)(int, int);
void func3(int a, int b) {}
// .....
FuncPtr ptr3 = func3;
ptr3(3, 4); // вызов
ptr3 = nullptr; // сброс
if (ptr3) ptr3(3, 4); // вызов если задан
Можно и массив функций сделать, почему нет:
typedef void (*FuncPtr)();
void func1() {}
void func2() {}
int main() {
FuncPtr arr[2];
arr[0] = func1;
arr[1] = func2;
// вызов
arr[0]();
arr[1]();
}
Лямбда-функция #
Обычные функции должны создаваться только на самом верхнем уровне программы, то есть вне других функций. Есть ещё лямбда-функции, их можно создавать внутри других функций. Синтаксис в C++ довольно своеобразный: [контекст](параметры){тело}
. Если с параметрами и телом всё как у обычных функций, то контекст
- это список переменных в области видимости лямбды, которые будут доступны внутри неё:
имя
- передать переменную по значению. Будет доступна только для чтения&имя
- передать переменную по ссылке. Будет доступна для чтения и записи=
- передавать все переменные по значению - доступны только для чтения&
- передавать все переменные по ссылке - доступны для чтения и записи
Вызывается лямбда-функция так же, как обычная, при помощи ()
.
int main() {
[](){}(); // корреткный код, вызов пустой лямбды
int a = 123;
int b = 456;
[a, &b]() {
//a = 11; // ошибка
b = 22;
}();
// b == 22
[=]() {
//a = 11; // ошибка
//b = 22; // ошибка
}();
[&]() {
a = 11;
b = 22;
}();
// a == 11, b == 22
// вызов с аргументами
[&](int na, int nb) {
a = na;
b = nb;
}(33, 44);
// a == 33, b == 44
}
Возврат значения #
Если лямбда должна вернуть значение, то она объявляется следующим образом: [контекст](параметры) -> тип {тело}
. Например сделаем функцию для сложения чисел:
int main() {
int sum = [](int a, int b) -> int {
return a + b;
}(5, 7);
// sum == 12
}
Указатель на лямбду #
На лямбду без контекста можно сделать указатель и вызывать её через него:
int main() {
// без параметров и возврата
void (*foo)() = []() {
// код
};
foo();
foo();
// с параметрами и возвратом - функция для сложения
int (*sum)(int a, int b) = [](int a, int b) -> int {
return a + b;
};
sum(3, 4); // 7
sum(15, 9); // 24
}
Рекурсия #
Рекурсивная функция - функция, которая вызывает сама себя, как матрёшка:
void foo() {
foo();
}
foo(); // запускаем матрёшку!
В данном примере функция будет вызывать себя до тех пор, пока не закончится оперативная память: каждый вызов функции приводит к созданию скрытой "переменной" с адресом возврата, а так как наш код никуда не возвращается - эти переменные будут просто накапливаться в памяти, пока она не закончится!
Пример с параметром: пусть функция увеличивает переданный ей параметр и вызывает себя снова:
void foo(int param) {
foo(param + 1);
}
foo(10); // катастрофа
С каждым вызовом функции её параметр param
будет увеличиваться на 1: 10, 11, 12, 13...
В этом случае память закончится примерно в два раза быстрее - параметр, как локальная переменная, между вызовами функции также сохраняется в памяти.
Давайте уже рассмотрим пример, который не сломает программу. В рекурсивной функции самое главное - точка выхода, то есть момент, когда функция перестанет вызывать сама себя и рекурсия "раскрутится обратно". Сделаем функцию, которая вызовет себя указанное количество раз:
void foo(int param) {
param--;
if (param) foo(param);
}
foo(5);
Функция принимает количество раз, которая должна себя вызвать. Уменьшает его на 1. Если оно больше нуля - вызывает себя с этим уменьшенным значением. В примере выше функция вызовет себя 5 раз.
Зачем же нужен такой опасный инструмент? Рекурсия - довольно сложная для понимания и очень мощная штука, позволяет создавать лаконичные и эффективные алгоритмы, некоторые примеры мы рассмотрим в следующих уроках.