АШШумилкин А. О.ОАиП · ПиТПМ
ОАиП · Лекция 04

Лекция №4. Функции: модульность и повторное использование кода.

3 397 слов12 разделов3 иллюстраций
↓ Скачать Markdown

§4.1. Понятие функции и принципы модульности

Функция

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

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

Функции помогают:

  • Избегать дублирования кода — один раз написали, много раз используем.
  • Упрощать отладку — если ошибка, можно проверить каждую функцию отдельно.
  • Повышать читаемость — код становится понятнее, особенно если функции имеют осмысленные имена (например, CalculateArea, PrintMenu).

В языках C++ и C# функции являются основным способом организации кода.

В C++ функции могут быть глобальными или частью классов. В C# все функции (методы) обязательно входят в состав классов.

Небольшое напутствие

в С++ программисты, именованный блок кода для многократного вызова, называют «функции», а в С# — «методы».

§4.2 Синтаксис определения функций

В дополнение к определению функции из параграфа §4.1. можно сказать, что функция (function) — это подпрограмма, которая может получать параметры на вход и возвращать выходное значение. Чтобы выполнить свою задачу, функция должна быть вызвана. Она является важнейшим элементом процедурного программирования, так как позволяет группировать и обобщать программный код, который может позднее использоваться произвольное число раз.

Общий синтаксис определения функции в языке C++ имеет следующий вид:

text

ТИП_ВОЗВРАТА ИМЯ_ФУНКЦИИ(СПИСОК_ПАРАМЕТРОВ) 
{ 
   //тело функции
}

Пример создания и вызова функции на языке С++:

cpp

void SayHello()
{
    std::cout << " Привет второй курс :)" << std::endl;
}

SayHello(); // Вызов функции

При определении функции C# (функции в данном языке программирования называются методами) используется следующая форма:

text

[модификаторы] ТИП_ВОЗВРАТА ИМЯ_МЕТОДА (СПИСОК_ПАРАМЕТРОВ)
{
   //тело метода
}

Пример создания и вызова функции на языке С#:

csharp

static void SayHello()
{
    Console.WriteLine("Привет второй курс :)");
}

SayHello(); // Вызов метода 

Здесь ТИП_ВОЗВРАТА определяет тип данного, возвращаемых функцией. Если функция не возвращает никакого значения, то используется тип void.

Имя функции определяется элементом ИМЯ_ФУНКЦИИ. В качестве имени допускается использовать любой идентификатор, если он еще не занят.

СПИСОК_ПАРАМЕТРОВ представляет собой последовательность пар типов и идентификаторов, разделяемых запятыми.

Параметры

– это переменные, которые получают значения аргументов, передаваемых функции при ее вызове.

Аргументы

– это значения, которые передаются из caller-а в вызываемую функцию. Если функции не требует параметров, то список параметров будет пуст.

Фигурные скобки окружают тело функции. Тело функции состоит из инструкций, которые определяют, что именно эта функция делает. Когда по ходу выполнения инструкций встречается закрывающаяся скобка, то функция завершается, и управление передается вызывающему коду.

§4.3 Вызов функции и аргументы

После того как функция определена, её можно вызвать — то есть запустить на выполнение. Для этого используется имя функции, за которым следуют круглые скобки с аргументами (данными, которые передаются в функцию).

Формат вызова:

text

ИмяФункции(аргументы);

Если функция возвращает значение, его можно сохранить в переменную или использовать в выражении:

text

тип переменная = ИмяФункции(аргументы);

Вновь повторимся, как было сказано в параграфе §4.2, аргументы — это конкретные значения, которые передаются в функцию при её вызове. Они соответствуют параметрам, указанным при определении функции. Обратите внимание на код на языке программирования С#:

csharp

int Add(int a, int b)  // a и b — параметры
{
    return a + b;
}
 
// Вызов функции:
int result = Add(3, 5); // 3 и 5 — аргументы

В данном случае, a и b – параметры (места – заполнители в определени), а 3 и 5 – аргументы (реальные значения при вызове).

При вызове функции необходимо учитывать следующие ограничения:

  • Количество аргументов должно совпадать с количеством параметров (если не используются параметры по умолчанию).
  • Типы аргументов должны быть совместимы с типами параметров.
  • Порядок аргументов имеет значение — первый аргумент подставляется в первый параметр и т.д.

text

double Power(double base, double exp)
{
    return pow(base, exp);
}
 
double res = Power(2.0, 3.0); // base = 2.0,exp = 3.0
// double res = Power(“abc”, 3.0); 
// ❌ Ошибка — тип string не подходит под double.

Если функция не возвращает значение (void), её вызов используется как отдельный оператор. Пример на языке программирования С#:

csharp

void PrintHello()
{
    Console.WriteLine("Привет!");
}
 
PrintHello(); // вызов — просто имя и скобки

Если функция возвращает значение, его можно:

  • Присвоить переменной.
  • Использовать в выражении.
  • Передать как аргумент в другую функцию.

csharp

int Square(int x)
{
    return x * x;
}
 
int a = Square(4);             // a = 16
int b = Square(3) + Square(4); // b = 9 + 16 = 25

Console.WriteLine(Square(5));  // выводит 25 

То же самое — в C++ (с заменой Console.WriteLine на std::cout).

Несмотря на то, что функции / методы удобная конструкция для работы с программой, в С++ есть ограничения, которые придется учитывать. Первое, функция не может быть объявлена внутри другой функции. Исключениями являются лишь лямбда – функции.

Пример создания и вызова лямбда – функции в С++:

csharp

#include <iostream>
 
void myFunction() {
    // Лямбда-функция, которая просто выводит сообщение
    auto greet = []() {
        std::cout << "Привет из лямбды!" << std::endl;
    };
 
    // Вызов лямбды
    greet();
}
 
int main() {
    myFunction();  // Выведет: Привет из лямбды!
    return 0;
}

Но стоит понимать, что код:

cpp

int main() {
   void myFunction() { }
   return 0;
}  

выдаст ошибку компиляции: «myFunction () was not declared in this scope». Это связано с тем, что определение функции не существует внутри текущей функции.

Второе ограничение, в C++ вы не можете вызвать функцию до объявления самой функции. Все потому, что компилятор не будет знать полное имя функции (имя функции, число аргументов, типы аргументов). Таким образом в примере ниже компилятор сообщит нам об ошибке:

cpp

int main() {
   Sum_numbers(1, 2);
   return 0;
}

int Sum_numbers(int a, int b) {
   cout << a + b;
}

Так, при вызове функции Sum_numbers() внутри функции main() компилятор не знает ее полное имя.

Конечно, компилятор C++ мог просмотреть весь код и определить полное имя функции, но этого он делать не умеет и нам приходится с этим считаться.

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

Прототип функции

— это функция, в которой отсутствует блок кода (тело функции).

В прототипе функции находятся:

  • Полное имя функции.
  • Тип возвращаемого значения функции.

Вот как правильно должна была выглядеть программа, написанная выше:

cpp

int Sum_numbers(int a, int b); // прототип функции

int main() {
   Sum_numbers(1, 2);
   return 0;
}

int Sum_numbers(int a, int b) { // сама функция
   cout << a + b;
}

Так, с помощью прототипа мы объясняем компилятору, что полным именем функции является Sum_numbers(int a, int b), и кроме этого мы говорим компилятору о типе возвращаемого значения, у нас им является тип int.

§4.4 Возвращаемые значения и оператор return

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

Оператор return выполняет две задачи:

  • Завершает выполнение функции.
  • Возвращает указанное значение вызывающему коду.

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

Тип значения, которое возвращает функция, должен совпадать с её возвращаемым типом, указанным при определении. Если функция объявлена как int, она должна вернуть целое число, если double — число с плавающей точкой, если string — строку и т. д.

text

int GetNumber()
{
    return 42; // корректно: int → int
} 

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

csharp

void PrintMessage(string message)
{
    if (message == null)
    {
        return; // выход из функции без возврата значения
    }

    Console.WriteLine(message);
}

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

text

int Max(int a, int b)
{
    if (a >= b)
        return a; 
    else
        return b;
    // если a >= b, то второй return не выполняется
}

Как было сказано выше, одна из задач оператора return – завершение работы функции. Поэтому, после оператора return любой код в теле функции игнорируется.

csharp

int BadFunction()
{
    return 10;
    Console.WriteLine("Этот код никогда не выполнится"); // ОШИБКА: недостижимый код 
}

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

text

double Square(double x)
{
    return x * x;
}

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

Пример ошибки при работе с функцией:

text

int CheckValue(int x)
{
    if (x > 0)
        return 1;

    // А если x <= 0? Куда идти? Ошибка компиляции!
}

Для того, чтобы исправить ошибку компиляции, необходимо добавить блок else и оператор return, который вернет необходимое значение в случае, если x <= 0.

§4.5 Функции с несколькими параметрами

Функция может принимать не один, а несколько входных данных — для этого в её определении указывается несколько параметров. Такие **функции **позволяют обрабатывать сложные зависимости и строить более гибкие алгоритмы.

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

Общий вид подобной записи:

text

возвращаемыйТип ИмяФункции(тип1 имя1, тип2 имя2, ..., типN имяN)
{
    // тело функции
}

Предположим, Вы пишете программу на вычисление площади прямоугольника. Для этого Вы использовали бы формулу:

wireframe

Площадь прямоугольника = Длина прямоугольника × Ширина прямоугольника

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

text

double CalculateArea(double length, double width)
{
    return length * width;
}

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

csharp

double area = CalculateArea(5.0, 3.0);
Console.WriteLine("Площадь: " + area); // Вывод: 15

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

  • Все параметры должны иметь указанный тип.
  • Имена параметров должны быть уникальными в пределах списка.
  • Типы аргументов при вызове должны быть совместимы с типами параметров (например, int можно передать в double, но не наоборот без приведения).

text

CalculateArea("длина", "ширина"); 
// ОШИБКА: тип string не подходит под double

Параметры функций похожи на локальные переменные. Они допустимы только в пределах функции. Так, параметры length и width функции CalculateArea() допустимы и пригодны для использования только внутри функции CalculateArea(), но не вне нее.

Технически ограничений почти нет, но на практике более 3–4 параметров — признак, что функция делает слишком много.

§4.6 Параметры функций со значениями по умолчанию

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

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

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

– это значение, которое автоматически подставляется в параметр, если при вызове функции соответствующий аргумент не передан.

В C++ значение по умолчанию указывается при объявлении функции (чаще всего в прототипе или определении):

text

возвращаемыйТип ИмяФункции(тип параметр = значение, ...)
{
    // тело функции
}

Для примера можно рассмотреть следующий код:

cpp

void PrintMessage(std::string message, int count = 1)
{
    for (int i = 0; i < count; i++)
    {
        std::cout << message << std::endl;
    }
}

Теперь функцию можно вызывать по-разному:

cpp

// выведет 1 раз (count = 1 по умолчанию)
PrintMessage("Привет");           
PrintMessage("Пока", 3); // выведет 3 раза

При работе со значениями по умолчанию, необходимо следовать двум правилам, чтобы не было ошибки компиляции:

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

cpp

// ✅ Так можно
void Func(int a, int b = 10, int c = 20);

// ❌ Так нельзя, так как ошибка: a — не в конце
void Func(int a = 5, int b, int c = 10);

Однако, на практике в C# чаще используют перегрузку методов, а не значения по умолчанию. Но если используется — синтаксис значений по умолчанию такой же:

csharp

void PrintMessage(string message, int count = 1)
{
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine(message);
    }
}

§4.7 Перегрузка функций (function overloading)

Перегрузка функций

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

Это удобно, когда одна и та же операция выполняется с разными типами данных или разным числом параметров.

Представьте, что вы хотите написать функцию Print, которая выводит на экран разные типы данных: число, строку, массив.

Можно дать им разные имена: PrintInt, PrintString, PrintArray — но это неудобно и неочевидно.

С перегрузкой можно просто написать:

text

Print(42);
Print("Hello");
Print(arr);

— и всё будет работать, если есть соответствующие версии функции Print.

Компилятор различает функции по их сигнатуре — комбинации: имени функции, типов параметров, количества параметров, порядка параметров.

Так же важно понимать, что возвращаемый тип не участвует в различении перегруженных функций.

text

// ✅ Так можно
int Add(int a, int b);
double Add(double a, double b);

// ❌ Так нельзя
int Get();
double Get(); 
// ОШИБКА: одинаковое имя и параметры — не перегрузка!

Для примера перегруженной функции рассмотрим следующий код:

text

// Версия 1: два целых числа
int Sum(int a, int b)
{
    return a + b;
}
 
// Версия 2: три целых числа
int Sum(int a, int b, int c)
{
    return a + b + c;
}
 
// Версия 3: два числа с плавающей точкой
double Sum(double a, double b)
{
    return a + b;
}

// Вызов
Sum(2, 3);        // вызовет Sum(int, int)
Sum(1, 2, 3);     // вызовет Sum(int, int, int)
Sum(2.5, 3.7);    // вызовет Sum(double, double)

В C# перегрузка работает точно так же, как и в С++, но только для методов внутри классов.

§4.8 Рекурсивные функции

В некоторых случаях функция может фактически вызывать сама себя. Такая функция называется рекурсивной (recursive function).

Любая корректная рекурсивная функция состоит из двух ключевых частей:

  • Базовый случай (условие остановки) — условие, при котором функция не вызывает себя, а возвращает конкретное значение.

Без базового случая рекурсия будет продолжаться бесконечно — приведёт к переполнению стека вызовов.

  • Рекурсивный случай — шаг, при котором функция вызывает себя с упрощённым или уменьшенным параметром, приближаясь к базовому случаю.

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

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

Давайте рассмотрим принцип работы рекурсивных функций на примере нахождения факториала числа. Но прежде давайте вспомним, что собой представляет факториал и как он вычисляется:

factorial(N)=1×2×3×⋯×(N−1)×N\text{factorial}(N) = 1 \times 2 \times 3 \times \dots \times (N-1) \times Nfactorial(N)=1×2×3×⋯×(N−1)×N

Проще говоря, факториал числа — это просто произведение всех чисел от 1 до N.

Чтобы перемножить всю заданную последовательность чисел, можно использовать цикл for.

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

factorial(N)=N×factorial(N−1)\text{factorial}(N) = N \times \text{factorial}(N-1)factorial(N)=N×factorial(N−1)

Это все равно что передать задачу по вычислению другому вызову функции, работающему с уменьшенной версией исходной задачи. Давайте проверим, соответствует ли это решение тому, которое мы имеем с циклом for, а для этого развернем наше равенство.

Рисунок 1 – Шаги рекурсивной функции для вычисления факториала

Рисунок 2 – Проверка правильности вычислений

Как видно по рисункам, наша рекуррентная формула вычисления факториала верна. Формализуем аналитическое представление рекуррентной формулы в виде программного кода:

text

int Factorial(int n)
{
    if (n == 0)           // базовый случай
        return 1;
    else                  // рекурсивный случай
        return n * Factorial(n - 1);
}

Рекурсивные функции могут пригодиться при вычислении чисел прогрессии Фибоначчи.

0,1,1,2,3,5,8,13,21,34,55,89,144,233,…0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, \dots0,1,1,2,3,5,8,13,21,34,55,89,144,233,…

В последовательности Фибоначчи каждое число представляет собой сумму двух предыдущих чисел, а первые два числа — это 0 и 1.

Общая формула для вычисления n – го числа Фибоначчи выглядит следующим образом:

F(n)=F(n−1)+F(n−2),где F(1)=F(2)=1F(n) = F(n-1) + F(n-2), \quad \text{где } F(1) = F(2) = 1F(n)=F(n−1)+F(n−2),где F(1)=F(2)=1

Определение последовательности Фибоначчи рекурсивно по своей природе, поскольку n-е число зависит от двух предыдущих чисел. Это означает, что задачу можно разделить на меньшие подзадачи, а это рекурсия.

text

int GetFibNumber(int n)
{
    if (n == 0)
        return 0;
    if (n == 1)
        return 1;
    return Fibonacci(n - 1) + Fibonacci(n - 2);
}

Каждая рекурсивная задача имеет две необходимые вещи:

  • Рекуррентное соотношение, определяющее состояния задачи и то, как основная задача может быть разделена на меньшие подзадачи. Сюда также входит базовый случай для прекращения рекурсии.
  • Дерево рекурсии, демонстрирующее несколько первых вызовов рассматриваемой функции (если не все). Взгляните на дерево рекурсии для рекурсивного соотношения последовательности Фибоначчи:

Рисунок 3 – Дерево рекурсии

§4.9 Делегаты и функциональные объекты

Делегат

— это тип, который представляет ссылку на метод с определённой сигнатурой (возвращаемым типом и списком параметров).

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

Делегаты являются основой для таких возможностей C#, как события, обратные вызовы (callbacks) и лямбда-выражения.

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

csharp

delegate int Operation(int x, int y);

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

После объявления делегат можно использовать как тип переменной:

csharp

1. Operation op = Add;  // op ссылается на метод Add
2. int result = op(3, 5); // вызов метода через делегат

§4.10 Добавление методов в делегат

Делегат в C# может ссылаться не только на один метод, но и на несколько методов одновременно. Такой делегат называется мультикаст-делегатом (multicast delegate).

Когда такой делегат вызывается, все присоединённые методы выполняются по очереди — один за другим.

Для добавления метода в делегат используется оператор +=:

text

делегат += имя_метода;

Это подписывает метод на выполнение при вызове делегата. Но важно понимать, что тип делегата и его сигнатура должны совпадать с методом.

csharp

void SayHello() => Console.WriteLine("Привет!");
void SayGoodbye() => Console.WriteLine("Пока!");
 
Action message = null;
 
message += SayHello;   // добавили первый метод
message += SayGoodbye; // добавили второй
 
message(); // сначала SayHello(), потом SayGoodbye()

Если метод больше не должен вызываться, его можно отписать с помощью оператора -=:

csharp

message -= SayGoodbye; // метод больше не будет вызываться

После этого вызов message() выполнит только SayHello().

§4.11 Анонимные методы

Анонимный метод

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

Анонимные методы позволяют писать код более компактно и без «размазывания» логики по разным частям программы.

В C# анонимный метод создаётся с помощью ключевого слова delegate, за которым (опционально) указываются параметры и тело в фигурных скобках.

text

delegate(параметры) { тело метода }

Для примера можно рассмотреть следующий код:

csharp

Action sayHi = delegate 
{
    Console.WriteLine("Привет!");
};
 
sayHi(); // вызов анонимного метода

Так же можно передавать параметры, как и в обычном методе:

csharp

Action<string> greet = delegate(string name)
{
    Console.WriteLine("Привет, " + name);
};
 
greet("Анна"); // вывод: Привет, Анна

Зачем же нужны анонимные методы? Иногда требуется выполнить простое действие — например, обработать событие, передать логику в метод, отфильтровать данные. Писать для этого отдельный именованный метод — избыточно.

csharp

Timer timer = new Timer(delegate(object obj)
{
    Console.WriteLine("Таймер сработал!");
}, null, 1000, 1000);

Здесь анонимный метод вызывается каждую секунду. Создавать отдельный метод ради одной строки — неудобно.

Так же стоит ввести понятие захват переменных. Анонимный метод может использовать переменные из внешнего кода — это называется захват переменных.

csharp

int count = 0;
Action increment = delegate
{
    count++; // использует переменную извне
    Console.WriteLine(count);
};
 
increment(); // 1
increment(); // 2  

Переменная count захватывается, и её значение сохраняется между вызовами.

§4.12 Лямбда – выражения и их использование

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

Лямбда – выражения имеют следующий синтаксис: слева от лямбда-оператора => определяется список параметров, а справа блок выражений, использующий эти параметры:

text

(список_параметров) => выражение.

Например:

csharp

class Program
{
   delegate int Operation(int x, int y);

   static void Main(string[] args)
   {
      Operation operation = (x, y) => x + y;

      Console.WriteLine(operation(10, 20)); // 30
      Console.WriteLine(operation(40, 20)); // 60

      Console.Read();
   }
}

Здесь код

text

(x, y) => x + y;

представляет лямбда – выражение, где x и y – это параметры, а x + y – выражение. При этом нам не надо указывать тип параметров, а при возвращении результата не надо использовать оператор return.

При этом надо учитывать, что каждый параметр в лямбда – выражении неявно преобразуется в соответствующий параметр делегата, поэтому типы параметров должны быть одинаковыми. Кроме того, количество параметров должно быть таким же, как и у делегата. И возвращаемое значение лямбда-выражений должно быть тем же, что и у делегата. То есть в данном случае использованное лямбда - выражение соответствует делегату Operation как по типу возвращаемого значения, так и по типу и количеству параметров.

Если лямбда-выражение принимает один параметр, то скобки вокруг параметра можно опустить.

csharp

delegate void Printer(string message);

Printer PrintMessage = text => Console.WriteLine("Сообщение: " + text);

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

csharp

using System;
 
// Объявляем делегат без параметров и с void-возвратом
delegate void Action();
 
// Лямбда-выражение: нет параметров → (), нет возврата → просто выводим
Action SayHello = () => Console.WriteLine("Привет, мир!");
 
// Вызов
SayHello();  // Вывод: Привет, мир!