Функции в С++ - это именованные блоки кода, которые могут вызываться многократно.
Функции нужны для декомпозиции, повторного использования и повышения поддерживаемости кода.
Вы не могли в изучении 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;
}
Вопрос только в том, насколько это является целесообразным. Так обычно не делают.
Параметры
Мы можем передавать различные параметры внутрь функции.
Давайте проведём аналогию с математическими функциями и запишем линейную функцию в коде.
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.