Неделя 8 · Foundation

Рекурсия и файлы

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

Что вы узнаете на этом уроке

Поймёте, что такое рекурсия и почему важен базовый случай
Увидите, как рекурсивная функция решает задачу, уменьшая её
Понаблюдаете, как работает стек вызовов (call stack)
Сравните рекурсию и цикл
Откроете и закроете файл с помощью fopen и fclose
Запишете в файл (fprintf) и прочитаете из файла (fgets)

8.1 Что такое рекурсия?

Рекурсия (recursion) — это когда функция вызывает саму себя. Услышав об этом впервые, легко удивиться: как функция может вызвать саму себя? Но идея проста: большую задачу мы превращаем в чуть меньшую, но точно такую же задачу.

Представьте два зеркала, обращённых друг к другу. В каждом зеркале — ещё одно зеркало, в нём — ещё одно, и так далее. Рекурсия устроена так же: внутри каждого вызова есть ещё одна, меньшая его копия.

Например, обратный счёт. sanash(3) сначала выводит 3, затем вызывает sanash(2), который выводит 2 и вызывает sanash(1), и так далее. Каждый вызов чуть меньше предыдущего.

У рекурсии две части: базовый случай (когда остановиться) и рекурсивный шаг (переход к меньшей задаче). Рассмотрим обе вместе.

8.2 Базовый случай (base case)

Если функция будет вызывать себя бесконечно, она никогда не остановится и программа упадёт. Поэтому в каждой рекурсии обязателен базовый случай (base case) — условие, останавливающее рекурсию. В обратном счёте ниже базовый случай — это n == 0: дойдя до него, функция не вызывает себя, а просто возвращается.

sanash.c
1void sanash(int n) {
2 if (n == 0) return; // базовый случай
3 printf("%d\n", n);
4 sanash(n - 1); // рекурсивный шаг
5}

Нажмите кнопку Выполнить и посмотрите, как работает sanash(3):

Обратный счёт Выполнить
terminal
Нажмите кнопку «Выполнить»: выведутся 3, 2, 1, а затем при n == 0 счёт остановится.

8.3 Рекурсивный шаг

На рекурсивном шаге задача каждый раз становится меньше и приближается к базовому случаю. Классический пример — факториал. n! — это произведение чисел от 1 до n. Рекурсивное определение очень красивое: n! = n * (n-1)!, а базовый случай — 1! = 1.

factorial.c
1int factorial(int n) {
2 if (n == 1) return 1; // базовый случай
3 return n * factorial(n - 1);
4}

Выберите число и посмотрите, как раскрывается факториал:

Раскрытие факториала выберите число
n =
terminal

8.4 Стек вызовов (call stack)

Теперь самое важное: что происходит внутри рекурсии? Каждый вызов функции добавляет в память новый фрейм в стек вызовов (call stack). Это в точности тот же стек (LIFO) с прошлой недели! Вызовы складываются друг на друга, а дойдя до базового случая, по очереди возвращаются, и ответы перемножаются.

С помощью кнопки Следующий шаг понаблюдайте за factorial(4). Зелёный фрейм — это вызов, который возвращается прямо сейчас:

Трассировщик стека вызовов проходите по шагам
вершина стека (последний вызов)

Обратите внимание: вызовы сначала складываются вниз (4, 3, 2, 1), а затем, начиная с базового случая, возвращаются вверх (1, 2, 6, 24). Именно поэтому рекурсия строится на стеке.

8.5 Рекурсия и цикл

Факториал можно было бы написать и циклом. Многие задачи решаются обоими способами. Выбор зависит от наглядности и природы самой задачи:

СвойствоРекурсияЦикл
Вид кодаиногда проще, естественнееиногда длиннее
Памятьфреймы стека (больше)компактно
Очень глубокостек может переполнитьсяпроблем нет
Подходящая задачадерево, папки, делимыепростое повторение

Коротко: для простого повторения хорош цикл. Но если задача делится на маленькие копии самой себя (обход дерева, папки внутри папок), рекурсия гораздо естественнее и легче читается.

8.6 Пример: Фибоначчи

Последовательность Фибоначчи — знаменитый пример рекурсии. Каждое число равно сумме двух предыдущих: fib(n) = fib(n-1) + fib(n-2). Базовые случаи: fib(0) = 0 и fib(1) = 1. Последовательность: 0, 1, 1, 2, 3, 5, 8, 13, ...

Выберите число и посмотрите результат и саму последовательность:

Фибоначчи выберите число
fib(
) = 13
Внимание: простой рекурсивный Фибоначчи работает медленно, потому что снова и снова вычисляет одно и то же значение (внутри fib(5) функция fib(3) вызывается несколько раз). Это можно ускорить позже с помощью мемоизации (memoization).

8.7 Файлы: открытие и закрытие

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

fayl.c
1FILE *f = fopen("matn.txt", "w"); // открытие
2// ... работа с файлом ...
3fclose(f); // закрытие, обязательно!

fopen открывает файл и возвращает на него указатель (FILE *). Второй параметр — режим: он указывает, с какой целью открывается файл:

  • "r" чтение (read): для чтения из существующего файла
  • "w" запись (write): новая запись, старое содержимое стирается
  • "a" добавление (append): дописывание в конец
Закончив работу с файлом, обязательно сделайте fclose — точно так же, как free после malloc. Иначе записанные данные могут не сохраниться на диск полностью.

8.8 Запись в файл

Для записи текста в файл используется fprintf. Он точно такой же, как printf, только первым параметром принимает указатель на файл: fprintf(f, "%s\n", matn). Введите строку ниже и запишите её в файл:

Запись в файл (fprintf) добавьте строку
matn.txt

8.9 Чтение из файла

Для чтения из файла fgets читает одну строку, а fscanf читает форматированные данные. Обычно мы читаем файл от начала до конца, строка за строкой, пока файл не закончится. Файл ниже уже готов. Читайте его по одной строке кнопкой Прочитать следующую строку:

Чтение из файла (fgets) строка за строкой
royxat.txt
terminal

Сделайте прогноз

Какой результат вернёт factorial(3)?

3
9
6

8.10 Глубже advanced

Ещё несколько важных аспектов рекурсии и файлов.

Переполнение стека

Если рекурсия слишком глубокая (или базовый случай задан неверно и она никогда не останавливается), стек вызовов переполняется. Это называется переполнением стека (stack overflow) и приводит к падению программы. Поэтому базовый случай должен быть правильным и всегда достижимым.

Текстовые и двоичные файлы

Файлы бывают двух видов. Текстовые файлы (txt) состоят из символов, которые человек может прочитать. А двоичные файлы (изображения, видео, базы данных) состоят непосредственно из байтов и читаются специальной программой. Оба вида открываются через fopen, отличаются только режим и способ чтения.

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

Словарь терминов

рекурсиявызов функцией самой себя.
базовый случайусловие, останавливающее рекурсию.
стек вызововстек, в котором хранятся вызовы функций.
fopen / fcloseоткрытие и закрытие файла.
fprintfзаписывает форматированный текст в файл.
fgetsчитает одну строку из файла.

8.11 Тест знаний

16 вопросов. Чтобы завершить неделю, ответьте правильно как минимум на 11 из них.

Поздравляем! Неделя 8 завершена

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

Следующая неделя: Отладка и итоговый проект (виды ошибок, их поиск и построение проекта). Это последняя неделя курса Foundation!

Перейти к итоговому модулю