View Categories

Функции

Функция - блок кода, который можно вызвать по его имени из другого блока кода или при инициализации переменной. Функция может возвращать значение в зависимости от переданных аргументов и своего внутреннего "устройства". Пример из математики - функция синус: возвращает значение координаты на оси в зависимости от переданного ей угла.

На низком уровне это работает примерно так: весь код хранится в постоянной памяти списком, друг за другом, инструкция за инструкцией. Функция - по сути набор инструкций, который находится по известному адресу в памяти. Допустим выполняется какой-то код и доходит до инструкции "перейти по адресу". Процессор сохраняет адрес текущей инструкции в оперативной памяти и переходит по адресу функции. Выполняет её код, а затем возвращается обратно по адресу, который запомнил - адресу возврата.

Также можно представить это в виде оператора 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 раз.

Зачем же нужен такой опасный инструмент? Рекурсия - довольно сложная для понимания и очень мощная штука, позволяет создавать лаконичные и эффективные алгоритмы, некоторые примеры мы рассмотрим в следующих уроках.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх