Структурирование кода с помощью функций

Функции и процедуры

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

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

public static void Greet()
{
    Console.WriteLine("=============");
    Console.WriteLine("|   Hello   |");
    Console.WriteLine("=============");
}

Содержимое первой строки называется сигнатурой функции. В общем случае, сигнатура может быть достаточно сложной синтаксической конструкцией, состоящей из множества разных элементов: ключевых слов, атрибутов, параметрических типов, распространенного списка аргументов и т.д. В приведённой выше сигнатуре выделим имя функции - Greet. Как и имена переменных, имя функции может состоять из букв и цифр, но не должно начинаться с цифры.

Сама по себе функция Greet является “мертвым кодом”, т.е. кодом, который не будет выполняться во время работы программы. Чтобы выполнить функцию, необходимо вставить код её вызова в какую-либо другую функцию, не являющуюся мёртвым кодом:

public static void Greet()
{
    Console.WriteLine("=============");
    Console.WriteLine("|   Hello   |");
    Console.WriteLine("=============");
}

public static void Main(string[] args)
{
    Greet();
    Greet();
}

Приведённый выше код содержит два вызова к функции Greet из тела функции Main. Таким образом, код Greet становится достижимым, и в результате выполнения такой программы на экран будет выведены две рамки с сообщением “Hello”.

Аргументы функций

В большинстве случаев не требуется, чтобы функция выполняла одно и то же действие от вызова к вызову. Чаще всего требуется совершать одинаковые действия над разными значениями, получая, таким образом, разные результаты. Например, функцию Greet можно изменить так, чтобы сообщение, печатаемое внутри рамки, задавалось в момент вызова функции, а не было жестко “зашито” в её код. Это достигается путем добавления в сигнатуру функции аргумента (параметра):

public static void Greet(string message)
{
    Console.WriteLine("=============");
    Console.WriteLine("|   " + message + "   |");
    Console.WriteLine("=============");
}

public static void Main(string[] args)
{
    Greet("Hello1");
    Greet("Hello2");
}

Аргументы перечисляются через символ “,” внутри круглых скобок после имени функции. Объявление каждого аргумента похоже на объявление обычной переменной, с тем лишь исключением, что у аргументов отсутствует инициализатор (часть кода справа от символа “=”, а так же сам символ “=”). Количество возможных аргументов функции не ограничено, однако следует стремиться к уменьшению их количества с целью упрощения чтения кода функции.

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

  1. Аргументы, перечисляемые в сигнатуре функции называются формальными аргументами. Формальные аргументы объявляются в виде T n, где T - некоторый тип и n - имя аргумента. Внутри тела функции формальные аргументы работают как обычные переменные, и содержат в начале выполнения значения фактических аргументов.
  2. Фактические аргументы перечисляются в месте вызова функции. Число фактических аргументов должно совпадать с числом формальных, так же как типы фактических аргументов должны совпадать с типами формальных. В момент вызова значение каждого фактического аргумента подставляется в соответствующий ему формальный, после чего выполнение программы переходит в тело вызываемой функции.

Возвращаемое значение

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

Рассмотрим код функции, принимающей два числа и возвращающей их сумму:

public static int Sum(int x, int y)
{
    int result = x + y;
    return result;
}

В сигнатуре функции перед её именем теперь фигурирует int, вместо void. Этот тип называется типом возвращаемого значения функции. Ключевое слово return в теле функции прерывает её выполнение, производит передачу управления в вызвавший код и возвращает туда значение выражения, которое фигурирует после этого ключевого слова. Разумеется, тип возвращаемого значения должен совпадать с типом выражения, следующего за return.

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

public static int Arithmetic(int x, int y, string op)
{
    if (op == "+")
        return x + y;
    else
        return x - y;
}

Наконец, рассмотрим код, принимающий значения из функции с не-void типом возвращаемого значения:

public static int Sum(int x, int y)
{
    int result = x + y;
    return result;
}

public static void Main(string[] args)
{
    int result = Sum(1, 2);
    Console.WriteLine(result); // 3
}

Понятие стека вызовов и рекурсии

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

В процессе работы функция G может, в свою очередь, вызвать еще одну функцию H. Так как этот вызов происходит до того, как G завершает выполнение, то в стек будет добавлен еще один адрес возврата. Таким образом, последовательные вызовы одной функции из другой будут приводить к увеличению глубины вложенности вызовов и росту стека. В Visual Studio, работающей в режиме отладки, имеется специльное окно, отображающее стек вызовов в текущий момент выполнения программы:

Рисунок 1. Окно стека вызовов

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

Функция называется рекурсивной, если её код содержит вызов к этой же самой функции. Чаще всего, этот вызов находится в ветви какого-либо условного оператора, т.к. в противном случае рекурсия получится бесконечной. Любой цикл, организованный с помощью for или while можно переписать в виде рекурсивной функции. Ниже приведён код, печатающий на экран числа от 0 до 10 без использования циклических конструкций:

static void Print(int x)
{
    Console.WriteLine(x);
    x = x + 1;
    if (x == 10)
        return;
    else
        Print(x);
}

public static void Main(string[] args)
{
    Print(0);
}

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

Аргументы командной строки

Можно заметить, что функция Main(), с которой начинается выполнение программы, принимает массив строк args в качестве своего единственного аргумента. Этот массив заполняется из аргументов командной строки, которые задаются в момент запуска программы. Когда программа запускается с помощью двойного щелчка на её значке, массив args будет пустым. Чтобы передать программе аргументы существуют следующие пути:

  1. Создать ярлык для запуска программы, открыть окно “Свойста…”, перейти на вкладку “Ярлык” и дописать желаемые параметры в поле “Объект”;
  2. Вызывать программу из консоли cmd.exe и дописать желаемые параметры через пробел;
  3. При запуске программы из Visual Studio аргументы командной строки можно указать в свойствах проекта в разделе “Debug”.

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

Контрольные вопросы

  1. В чем отличие формальных и фактических аргументов?
  2. Какую информацию содержит синтаксическая конструкция, называемая “сигнатура функции”?
  3. Каким образом можно вернуть несколько однотипных значений из функции?
  4. Можно ли вызвать функцию с не-void возвращаемым значением, не сохраняя результат в какую-либо переменную?

Задание на лабораторную работу

Изменить код программы для лабораторной работы №1 следующим образом:

  1. Разбить код функции Main на отдельные функции, например: функция, получающая ввод пользователя; функция, непосредственно производящая расчет; функция, выводящая результаты вычислений на экран. Функции должны передавать друг другу данные с помощью аргументов и возвращаемых значений.
  2. Трансформировать код цикла, производящего расчет в рекурсивную функцию