Функции в С++ - это именованные блоки кода, которые могут вызываться многократно.

Функции нужны для декомпозиции, повторного использования и повышения поддерживаемости кода.

Вы не могли в изучении C++ дойти до темы функций ни разу не столкнувшись функциями.

Причиной этому является функция main.

int main() {
    return 0;
}

В этом случае функция действительно называется main, возвращает тип int.

Мы можем создавать свои функции.

Возврат значения и оператор return

Из введения вы знаете, что возвращаемое значение в функции main является кодом завершения программы, а так же то, что код без вызова return в этой функции может быть скомпилирован.

Это правило не работает, если мы объявляем свою функцию.

int f() {

} // 'f': must return a value

int main() {
    std::cout << f() << std::endl;
}

Если мы объявляем функцию любого типа отличного от типа void - мы обязаны возвращать из неё значение посредством оператора return.

Этот код при компиляции будет выдавать ошибку.

int f() {
    return "abc"; // 'return': cannot convert from 'const char [4]' to 'int'
}

Возвращаемое выражение должно быть совместимо с возвращаемым типом функции и может подвергаться стандартным преобразованиям типов.

Значение, возвращаемое из функции будет подставлено/присвоено в месте вызова.

int f() {
    return 22;
}

int main() {
    std::cout << f() << std::endl;
}
22

Досрочное завершение выполнения

Как и в случае с циклами мы можем досрочно прекратить выполнение функции и вернуть значение.

int main() {
    std::cout << "Hello 1" << std::endl;
    return 0;
    std::cout << "Hello 2" << std::endl;
}
Hello 1

Hello 2 не будет выведено, потому что после выполнения return следующий код не выполняется.

Если функция имеет тип void мы всё равно можем использовать return для досрочного её завершения.

#include <iostream>

void sayHello() {
    std::cout << "Hello 1" << std::endl;
    return ;
    std::cout << "Hello 2" << std::endl;
}

int main() {
    sayHello();
    return 0;
}
Hello 1

Возврат при ветвлении

Для объяснения такого поведения нужно будет привести немного сложный пример.

Если у нас есть ветвление кода в функции, то все ветки кода должны возвращать значение.

int f() {
    if (false)
    {
        return 1;
    }
    else {
        
    }
}

int main() {
    volatile int a = 55555555;
    std::cout << f() << std::endl;
}
55555555

В этом примере мы выводим результат выполнения функции f.

Проблема в том, что встаёт вопрос - что является результатом выполнения функции f?

При входе в функцию есть условный оператор, который из-за false в условии переведёт управление потоком на второй блок кода, который не возвращает значение.

Для компилятора это конструкция оказалось корректной, но с точки зрения логики - она не верная, а если смотреть с точки зрения написания кода - это грубейшая ошибка, которая в данном случае приводит к неопределённому поведению.

В коде перед вызовом f я объявляю и инициализирую volatile int a = 55555555, volatile используется для того, чтобы компилятор не игнорировал инициализацию неиспользуемой переменной a.

Таким образом, значение переменной a в машинном коде гарантированно будет записано в память.

Вот как выглядит код, который был сгенерирован

.text:0000000140001000 arg_0           = dword ptr  8
.text:0000000140001000
.text:0000000140001000                 sub     rsp, 28h
.text:0000000140001004                 mov     rcx, cs:?cout
.text:000000014000100B                 mov     [rsp+28h+arg_0], 34FB5E3h
.text:0000000140001013                 mov     edx, [rsp+28h+arg_0]
.text:0000000140001017                 call    cs:??6?$basic_ostream
.text:000000014000101D                 mov     rcx, rax
.text:0000000140001020                 lea     rdx, sub_140001040
.text:0000000140001027                 call    cs:??6?$basic_ostream
.text:000000014000102D                 xor     eax, eax
.text:000000014000102F                 add     rsp, 28h
.text:0000000140001033                 retn
.text:0000000140001033 main            endp

В части случаев возвращение значений из функций осуществляется через регистры, но тут отсутствует вызов f(), потому что компилятор видя неопределённое поведение решил убрать этот вызов и просто вызвать оператор <<.

Ввиду такой реализации компилятора и стечения обстоятельств мы имеем ситуацию, когда компилятор полностью устраняет вызов функции f и использует в качестве выводимого значения ранее записанное значение 34FB5E3h (55555555 в десятичной) переменной a.

Это поведение не гарантированно, потому что при неопределённом поведении компилятор вправе выдать любой код.

Тип void

Не каждая функция должна возвращать значение.

Если функция выполняет какое-либо действие, но результат вычисления не нужен, используется специальный тип void.

Функция с возвращаемым типом void не обязана содержать оператор return.

void printHello() {
    std::cout << "Hello" << std::endl;
}

int main() {
    printHello();
}
Hello

Такая функция выполняет действие внутри своего тела, но после завершения не передаёт никакого значения обратно в место вызова.

Тип void - это специальный тип в C++, который означает отсутствие значения.

Попытка вернуть значение из функции типа void является ошибкой.

void f() {
    return 0; // return value type does not match the function type
}

Тип возвращаемого значения не совпадает с типом возвращаемого значения функции.

При этом внутри void функции можно использовать оператор return без значения для досрочного завершения выполнения.

При выводе результата выполнения функции с возвращаемым типом void мы получим ошибку.

void printHello() {
    std::cout << "Hello" << std::endl;
}

int main() {
    std::cout << printHello() << std::endl;
    //binary '<<': no operator found which takes a right-hand operand 
    //of type 'void' (or there is no acceptable conversion)
}

Вывод для типа void не определён.

Обычно в этом отсутствует необходимость, но это можно обойти перегрузкой оператора <<.

void printHello() {
    std::cout << "Hello World" << std::endl;
}

std::ostream& operator<<(std::ostream& os, void (*f)()) {
    f();
    return os;
}

int main() {
    std::cout << printHello;
}

Вопрос только в том, насколько это является целесообразным. Так обычно не делают.

Параметры

Мы можем передавать различные параметры внутрь функции.

Давайте проведём аналогию с математическими функциями и запишем линейную функцию f(x)=x+2f(x) = x + 2 в коде.

int f(int x) {
    int y = x + 2;
    return y;
}

Тут мы явно указываем что вычисляется вторая координата, но такой код избыточен и зачастую можно это опустить.

int f(int x) {
    return x + 2;
}

Вызов и пример результата будет выглядеть так.

std::cout << f(4) << std::endl;
6

При описании функции, после названия функции в скобках мы указываем список параметров.

При вызове функции в коде мы передаём фактические значения - аргументы.

Таким образом int x - это параметр, а 4 - это передаваемый аргумент.

Параметров может не быть вовсе или быть несколько, во втором случае мы будем обязаны передать соответствующее количество аргументов.

int f(int x, int y) {
    return x * x + y * y;
}

int main() {
    std::cout << f(2, 3) << std::endl;
}
13

Значения по умолчанию

Количество параметров и количество передаваемых аргументов может отличаться.

Мы можем задавать значения параметров по умолчанию.

int sum(int a, int b = 5) {
    return a + b;
}

int main() {
    std::cout << sum(2) << std::endl;
}
7

В этом случае если мы не передаём второй аргумент значение параметра b по умолчанию будет равно 5,

Если мы передадим в вызов два аргумента, допустим, sum(2,2) - получим результат 4, потому что значение по умолчанию будет проигнорировано.

Приведение типов при передаче аргументов

int sum(int a, int b) {
    return a + b;
}

int main() {
    std::cout << sum(1.9, 2.9) << std::endl;
}
3

При таком коде передаваемые литералы типа double неявно преобразуются в тип аргументов - int.

Объявление и определение

При работе с функциями существуют три важных понятия, которые нужно отличать.

Первое из них - сигнатура.

Сигнатура функции включает имя функции и типы параметров (порядок и количество важны).

Важно отметить, что возвращаемый тип и имена параметров в сигнатуру не входят, чего нельзя сказать о следующих понятиях.

Объявление

В момент объявления функции мы сообщаем компилятору о типе возвращаемого значения и принимаемых параметров, но при этом мы не пишем её реализацию.

Объявление выглядит следующим образом

int sum(int a, int b);

Если попытаться вызвать функцию имея только объявление без определения мы получим ошибку

int main() {
    std::cout << sum(1, 2) << std::endl; 
    //unresolved external symbol "int __cdecl sum(int,int)" (?sum@@YAHHH@Z)
}

Иными словами определение функции sum не найдено.

Определение

Определение - фактическая реализация функции.

int sum(int a, int b); // объявление

int main() {
    std::cout << sum(1, 2) << std::endl;
}

int sum(int a, int b) { // определение
    return a + b;
}
3

В этом случае компиляция успешная, код корректен.

Если объявление отсутствует - определение считается объявлением.

Если убрать из этого кода объявление, то из-за того, что функция sum используется до её определения - код не будет скомпилирован.

При объявлении мы имеем возможность не указывать имена параметров, то есть вместо int sum(int a, int b); объявить как int sum(int, int);.

Когда в этом есть необходимость

До этого в примерах присутствовало только определение, без объявления - это корректно.

Но, существуют случаи, когда без объявления не обойтись

Циклическая зависимость

Иногда существует зависимость между функциями.

Компилятор узнаёт о существовании тех-или иных сущностей сверху вниз разбирая код.

Если у нас встретится сущность, которая объявлена ниже, но используется раньше то мы получим ошибку.

#include <iostream>

void second();

void first()
{
    std::cout << "first" << std::endl;
    second(); 
}

void second()
{
    std::cout << "second" << std::endl;
    first();
}

int main()
{
    first();
    return 0;
}

Тут ошибки не возникает, потому что в месте первого вызова second() компилятор уже знает, что такая функция была заявлена в её определении.

Если убрать определение void second(); мы получим ошибку 'second': identifier not found.

Разделение интерфейса и реализации

В больших проектах возникает необходимость разбивать код на меньшие участки и разносить его по файлам.

По мимо этого вы можете встретить разные типы файлов .c, .cpp, .cc, ccx, .h и .hpp.

Обычно в файлах .h пишут объявления, а в файлах .cpp саму реализацию, а далее используют #include для подключения заголовочных файлов.

В больших проектах такое разделение позволяет экономить время при сборке.

Есть ещё некоторые нюансы, связанные с этим, но это не тема функций.

Побочные эффекты

Мы можем использовать вызовы функций как части логического условия, если возвращаемое значение может быть приведено к типу bool.

Как вам уже известно - короткое замыкание может влиять на выполнение частей условия.

Оформите подписку, чтобы иметь доступ ко всем публикациям без ограничений. Вы получите мгновенный доступ к полным версиям материалов, исходному коду и новым публикациям сразу после их выхода.

Я регулярно публикую материал и разборы ситуаций для различных игр, движков, а так же подходов разработки включая Internal, External и DMA.

Эта публикация содержит только часть статьи.

Полные уроки по C++, реверс-инжинирингу и разработке софта вы можете найти тут: