Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

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

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 20 · С нуля → практика

Рекурсия: категории, меню и обход деревьев

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

Рекурсивный результат: от готового списка к дереву категорий

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

Что переносим в следующую задачу
  1. Внешний вызов ждёт готовый результат всей структуры.
  2. Вызов для ветви получает только её данные и имеет свои местные переменные.
  3. Условие остановки завершает конечную ветвь.
  4. Результат ветви возвращается в ожидающее выражение; обход продолжает соседей.
  5. В конце внешний код получает итог либо описанную ошибку, а не молчаливо оборванную часть.

Дальше задачи сложнее: дерево категорий может храниться плоскими строками с parent_id, в данных может быть цикл, глубину нужно ограничить, а вместо рекурсивных вызовов можно использовать явный стек. Эти случаи добавляют договоры и проверки; они не меняют базовый маршрут аргумент → местный параметр → тело → return → вызывающее выражение.

Когда функция вызывает себя

Наглядная модель

Вложенные категории похожи на дерево папок

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

  • Инструмент

    Обрабатываем эту категорию и затем её дочерние категории.

    • Ручной

      Лист дерева: молотки, отвёртки. Дальше спускаться некуда.

    • Электрический

      Ветка может содержать дрели и их подкатегории.

  • Крепёж

    После возврата из ветки «Инструмент» переходим к соседней.

    • Саморезы

      Тот же алгоритм применяется к очередной дочерней категории.

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

Ранее неизвестная глубина была уже записана вложенными PHP-массивами. Здесь другая исходная форма: каждая категория — отдельная строка с id и номером родителя parent. Нужно получить читаемое меню «Каталог → Одежда → Куртки; Обувь». Порядок входных строк может начинаться с ребёнка, поэтому сначала строим быстрый список детей каждого родителя.

Каталог магазина содержит категории, у каждой могут быть подкатегории такой же формы. Вместо заранее написанных пяти вложенных foreach удобно определить один шаг: обработать текущую категорию, затем применить то же правило к детям. У рекурсии обязательно есть база завершения — отсутствие детей или достигнутый предел. Каждый вызов имеет свои локальные переменные; после окончания дочернего вызова программа возвращается к следующему ребенку родителя.

Обход в глубину
  1. Каталог: вывести
  2. Войти в Одежда: вывести
  3. Войти в Куртки: вывести, детей нет
  4. Вернуться в Одежда, затем в Каталог
  5. Войти в Обувь: вывести

Таблица базы обычно хранит плоские записи id, parent_id, title. Это еще не готовое дерево. Можно один раз сгруппировать записи по parent_id, а затем обходить индекс. Не делайте SQL-запрос для каждого узла: при тысяче категорий получите около тысячи обращений вместо одного ограниченного чтения или подходящего рекурсивного SQL. Порядок исходных строк не обязан ставить родителей раньше детей.

Индекс детей и проверка пути

Исходная строка ['id' => 3, 'parent' => 2, 'title' => 'Куртки'] говорит: категория 3 называется Куртки и находится у родителя 2. Это ещё не вложенная категория. Условный parent = 0 обозначает корень; это договор наших данных, не правило PHP.

Сначала построить и увидеть индекс детейСамостоятельный пример
<?php
$rows = [
    ['id' => 3, 'parent' => 2, 'title' => 'Куртки'],
    ['id' => 1, 'parent' => 0, 'title' => 'Каталог'],
    ['id' => 2, 'parent' => 1, 'title' => 'Одежда'],
    ['id' => 4, 'parent' => 1, 'title' => 'Обувь'],
];
$children = [];
foreach ($rows as $row) {
    $parent = $row['parent'];
    $children[$parent][] = $row;
}
echo 'Ребёнок родителя 0: ', $children[0][0]['title'], "\n";
echo 'Первый ребёнок родителя 1: ', $children[1][0]['title'], "\n";
echo 'Второй ребёнок родителя 1: ', $children[1][1]['title'], "\n";
echo 'Ребёнок родителя 2: ', $children[2][0]['title'], "\n";
Ожидаемый результат
Ребёнок родителя 0: Каталог
Первый ребёнок родителя 1: Одежда
Второй ребёнок родителя 1: Обувь
Ребёнок родителя 2: Куртки

Первый foreach рассматривает каждую строку один раз. Children[parent] — список детей этого родителя, [] добавляет туда целую row. В children[1][1][title] выбираются родитель 1, его второй ребёнок, затем название. Куртки могли прийти раньше Одежды: индекс не требует предварительной сортировки id.

Параметр printCategoriesЗначение первого вызоваЧто меняет вложенный вызов
array childrenВесь готовый индексЧитается тот же набор детей; функция его не переписывает.
int parent0: искать корневые категорииСтановится id текущей категории: теперь ищем её детей.
array path = []Пока нет предковКопия пути получает текущий id; повтор id в этой ветви означает цикл.
int depth = 0Корень без отступаУвеличивается на 1 для следующего уровня; ограничен максимумом 20.
: voidПолезного возвращаемого значения нетКаждый вызов печатает свою часть отчёта; выполнение затем возвращается родителю.

$children[$parent] ?? [] даёт пустой список, если детей нет: foreach тогда не запускает тело, вызов заканчивается. str_repeat(' ', $depth) возвращает строку из повторённых пар пробелов для отступа; echo сразу использует её. isset($path[$id]) проверяет, встречали ли текущего предка в этой ветви. Исключение останавливает неверный путь; оно не исправляет данные.

Вывести всё меню, включая соседнюю ветку ОбувьСамостоятельный пример
<?php
$rows = [
    ['id' => 3, 'parent' => 2, 'title' => 'Куртки'],
    ['id' => 1, 'parent' => 0, 'title' => 'Каталог'],
    ['id' => 2, 'parent' => 1, 'title' => 'Одежда'],
    ['id' => 4, 'parent' => 1, 'title' => 'Обувь'],
];
$children = [];
foreach ($rows as $row) {
    $children[$row['parent']][] = $row;
}
function printCategories(array $children, int $parent, array $path = [], int $depth = 0): void
{
    if ($depth > 20) {
        throw new RuntimeException('Слишком глубокое дерево');
    }
    foreach ($children[$parent] ?? [] as $category) {
        $id = $category['id'];
        if (isset($path[$id])) {
            throw new RuntimeException('Цикл категорий');
        }
        echo str_repeat('  ', $depth), $category['title'], "\n";
        $nextPath = $path;
        $nextPath[$id] = true;
        printCategories($children, $id, $nextPath, $depth + 1);
    }
}
printCategories($children, 0);
echo "Меню закончено\n";
Ожидаемый результат
Каталог
  Одежда
    Куртки
  Обувь
Меню закончено

Первый вызов печатает Каталог, затем вложенный ищет детей 1. Он печатает Одежда и входит к детям 2; там печатает Куртки и возвращается из пустых детей 3. После возврата foreach родителя 1 продолжает следующего ребёнка Обувь. Перед вложенным вызовом нет return: иначе соседние ветви были бы потеряны. Итоговый echo после самого первого вызова подтверждает возвращение наружу.

Возврат управления из самой глубокой ветви
  1. У Курток детей нет: foreach по [] не делает ни одного прохода.
  2. Заканчивается вызов для parent = 3; продолжается тело вызова parent = 2 после рекурсивной строки.
  3. Дети Одежды закончились; вызов parent = 2 возвращается в обход детей Каталога.
  4. Тот foreach берёт следующий элемент Обувь. После всех возвратов выполняется «Меню закончено».

Договор примера: id уникальны, parent ссылается на существующую категорию или корень 0, title является строкой. Проверка текущего пути ловит только достижимые циклы. Сироты и отдельная циклическая компонента без связи с 0 не будут обойдены; нужно отдельно сопоставить все id с посещёнными и валидировать каждую компоненту. Если нужна вся готовая строка или массив до вывода, накапливайте результат и возвращайте его: этот void-вариант мог уже напечатать часть меню перед поздним исключением. Для HTML название экранируют по контексту.

Рекурсивно извлечь числа из ограниченной структуры

Теперь полезный результат рекурсии — одно число, которое после вызова позволит принять заказ. Вся структура должна содержать только остатки одного и того же товара на разных складах. Нельзя складывать так произвольный заказ с id, ценой и количеством: одинаковый тип int ещё не означает одинаковый смысл.

Вход sumStocksЗачем нужен
array nodeПоддерево складских остатков; каждый лист — неотрицательный int.
int depth = 0Текущая глубина; вложенный вызов получает depth + 1.
: intСумма всех допустимых листьев текущего поддерева; возвращается вызывающему коду.
Вернуть общую доступность товара и использовать её для заказаСамостоятельный пример
<?php
function sumStocks(array $node, int $depth = 0): int
{
    if ($depth > 10) {
        throw new InvalidArgumentException('Превышена вложенность');
    }
    $sum = 0;
    foreach ($node as $value) {
        if (is_array($value)) {
            $sum += sumStocks($value, $depth + 1);
        } elseif (is_int($value) && $value >= 0) {
            $sum += $value;
        } else {
            throw new InvalidArgumentException('Остаток должен быть неотрицательным int');
        }
    }
    return $sum;
}
$stocksOfTea = ['north' => ['shelf1' => 3, 'shelf2' => 0], 'south' => ['shelf1' => 4]];
$available = sumStocks($stocksOfTea);
$requested = 6;
echo 'Всего чая: ', $available, ' уп.', "\n";
if ($available >= $requested) {
    echo 'Можно принять заказ на ', $requested, ' уп.', "\n";
    echo 'После заказа останется: ', $available - $requested, ' уп.', "\n";
}
Ожидаемый результат
Всего чая: 7 уп.
Можно принять заказ на 6 уп.
После заказа останется: 1 уп.

Вложенный северный вызов складывает 3 и 0, возвращает 3 в += внешнего sum. Южный возвращает 4 в следующий +=. Внешний sum стал 7 и возвращён в available. Следующая строка программы сравнивает 7 с запросом 6 и считает остаток 1. Пример лишь рассчитывает возможность: фактическое резервирование склада требует отдельной согласованной операции.

Отдельные суммы каждого вызова
  1. Корневой sum сначала 0.
  2. Северный sum — другая местная переменная: 0 → 3 → 3; return 3.
  3. Корневой sum получает 0 + 3.
  4. Южный местный sum: 0 → 4; return 4.
  5. Корневой sum получает 3 + 4; return 7 → available.

Глубина ограничена десятью переходами от корня 0. Пустой массив возвращает 0, потому что foreach ничего не добавил. Строка "3", отрицательное число, null или объект нарушают договор и вызывают исключение. Лимит глубины ограничивает и самоссылочный массив, но не сообщает точную причину цикла. Для внешней огромной структуры нужны также лимит числа узлов и проверка переполнения общей суммы до сложения.

Когда нужен явный стек

Глубокий пользовательский документ может исчерпать память стека. Итеративный обход хранит очередь/стек в массиве или SplQueue и делает предел явным. Он не устраняет циклы автоматически: все равно нужны visited или текущий путь в зависимости от задачи. Для настоящего дерева однократный обход посещает каждый узел; повторный поиск детей полным сканированием всех строк на каждом уровне может стать квадратичным. Индекс детей требует дополнительной памяти, но предотвращает повторный поиск.

В графе узел может быть достижим двумя путями. Глобальный visited означает «обработать узел однажды», путь предков означает «запретить цикл, но разрешить повтор в другой ветви». Для меню категорий обычно ожидается один родитель; если API допускает много родителей, сначала решите, должны ли товары появляться в нескольких ветвях. Не выбирайте структуру проверки случайно.

Попробуйте сами. В данные добавили parent=3 для категории 1. Почему вызов от parent0 может ничего не заметить?

Разбор решения

Циклическая компонента потеряла связь с корнем 0 и не будет достигнута. После обхода сравните набор посещенных ID сос всеми ID; оставшиеся — сироты или отдельные компоненты. Для полной валидации запускайте DFS каждой непосещенной компоненты с цветами состояния либо проверяйте предков каждого узла.

Рекурсивный вызов можно заменить явным списком ожидающей работы. Ниже стек хранит ещё не обработанные массивы. array_pop($pending) удаляет последний элемент и возвращает его; это значение занимает место вызова в присваивании node. Пока pending не пуст, работа продолжается. Здесь нет рекурсивного вызова самой функции.

Тот же итог через явный стек подмассивовСамостоятельный пример
<?php
$stocksOfTea = ['north' => ['shelf1' => 3, 'shelf2' => 0], 'south' => ['shelf1' => 4]];
$pending = [$stocksOfTea];
$total = 0;
$visitedValues = 0;
while ($pending !== []) {
    $node = array_pop($pending);
    foreach ($node as $value) {
        $visitedValues++;
        if ($visitedValues > 100) {
            throw new RuntimeException('Слишком много значений');
        }
        if (is_array($value)) {
            $pending[] = $value;
        } elseif (is_int($value) && $value >= 0) {
            $total += $value;
        } else {
            throw new InvalidArgumentException('Ожидается неотрицательный целый остаток');
        }
    }
}
echo 'Доступно чая по всем складам: ', $total, ' уп.', "\n";
echo 'Резервный список работы пуст: ', $pending === [] ? 'да' : 'нет', "\n";
Ожидаемый результат
Доступно чая по всем складам: 7 уп.
Резервный список работы пуст: да

После первого прохода в pending находятся северный и южный массивы; array_pop берёт последний, поэтому юг обрабатывается раньше севера. Для суммы это не меняет итог 7, но для печати меню порядок важен. Проверка 100 значений ограничивает работу этого маленького опыта, включая бесконечный обход циклических данных, однако не определяет структуру цикла и не контролирует переполнение суммы. Явный стек использует память для ожидающих ветвей.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

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

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.