Глава 38 · От основ к архитектуре
Зачем ООП: от корзины в массиве к правилам магазина
Разбираемся, когда классы действительно помогают: единицы денег, допустимые состояния, повторное использование правил и замена внешних сервисов.
Сначала полезный инструмент: одинаковые ценники для двух магазинов
Нужно печатать ценники двух магазинов: «Чайный дом: Чай» и «Кофейня: Кофе». Способ составления подписи одинаковый; отличается сохранённое название магазина. На этой маленькой задаче видно, какую роль играет объект, без базы данных, оплаты и десятков классов.
Что получится. Один раз настроим два независимых инструмента. Затем получим подпись коротким вызовом с названием товара.
- Вход: товар Чай и настройка Чайный дом
- Операция label соединяет сохранённую настройку с товаром
- Возвращённая строка попадает в echo
- Второй инструмент использует свою настройку Кофейня
Нужны переменные и вызовы функций: сохранение значения, аргументы и return. Здесь мы объясним новый способ держать настройки рядом с действием.
Работающая функция — честная исходная точка
Корзина хранит данные, методы сохраняют их согласованность
Покупатель меняет количество чая после применения скидки. Заказ должен получить новый итог, а не старое число.
- Каталог задаёт цену
TEA стоит 15000 копеек. Цена берётся из доверенного каталога, количество — из проверенного ввода.
- Внутри Cart
Данные одной корзины принадлежат конкретному объекту.
- items
TEA: цена 15000, количество сначала 2, затем 3.
- discountPercent
Скидка 10%. Внешний код не записывает private-поле напрямую.
- changeQuantity проверяет изменение
3 принимается. 0 отклоняется до изменения корзины; прежнее допустимое состояние остаётся.
- orderData заново считает итог
2 товара: 27000. После изменения на 3: 40500. Итог не берётся из устаревшей отдельной переменной.
Расчёт остаётся обычной функцией calculateTotal. Польза объекта — в месте хранения состояния и проверенных операциях над ним; такую же дисциплину можно построить процедурно.
Начнём с обычной функции. Параметр — имя входного значения внутри её тела. Вызов передаёт конкретные строки, а return возвращает подпись в место вызова. Одну функцию можно хранить в отдельном файле и подключать в разных программах; её не требуется копировать для каждого магазина.
<?php
declare(strict_types=1);
function label(string $shop, string $product): string
{
return $shop . ': ' . $product;
}
echo label('Чайный дом', 'Чай'), "\n";
echo label('Кофейня', 'Кофе'), "\n";
Ожидаемый результат
Чайный дом: Чай
Кофейня: Кофе
string перед параметром означает строку; : string после скобок — строковый результат. Точка соединяет тексты. При первом вызове $shop получает Чайный дом, $product — Чай; return отдаёт готовую строку в echo. Следующий вызов выполняется с другой парой значений.
Если операция одна и передавать две строки удобно, на этом можно остановиться. ООП не делает соединение строк быстрее и не улучшает этот результат само по себе. Новый повод появится, когда один и тот же набор настроек нужен целой группе операций.
Требование выросло: настройка нужна для нескольких вызовов
Магазин печатает ценники нескольких товаров. В функции мы можем хранить название в переменной и передавать его повторно. Это нормальное решение. Сложность состоит не в копировании функции, а в ответственности за то, какие настройки использует конкретная операция.
<?php
declare(strict_types=1);
function label(string $shop, string $product): string
{
return $shop . ': ' . $product;
}
$shop = 'Чайный дом';
echo label($shop, 'Чай'), "\n";
echo label($shop, 'Сахар'), "\n";
Ожидаемый результат
Чайный дом: Чай
Чайный дом: Сахар
Настройка хранится в $shop, функция получает её явно. Так можно строить и большое приложение: общие функции, проверенные структуры данных и явные параметры остаются полноценным подходом.
Теперь хотим удобный инструмент, который уже знает свой магазин: передавать в label только товар. Объект даст отдельное место для его настроек и связанных действий.
Объект хранит настройку рядом с действием
<?php
declare(strict_types=1);
class LabelPrinter
{
public string $shop = '';
public function label(string $product): string
{
return $this->shop . ': ' . $product;
}
}
$tea = new LabelPrinter();
$tea->shop = 'Чайный дом';
$coffee = new LabelPrinter();
$coffee->shop = 'Кофейня';
echo $tea->label('Чай'), "\n";
echo $coffee->label('Кофе'), "\n";
Ожидаемый результат
Чайный дом: Чай
Кофейня: Кофе
Класс LabelPrinter описывает устройство инструмента один раз. new создаёт каждый отдельный экземпляр. $tea и $coffee хранят разные объекты; у каждого своё поле shop. Метод label — функция, объявленная внутри класса. Во время $tea->label(...) переменная $this означает объект $tea.
| Запись в примере | Что делает каждый элемент |
|---|---|
class LabelPrinter { ... } | class объявляет класс; имя выбираем сами; фигурные скобки ограничивают его описание. Само объявление ещё не создаёт ни одного настроенного принтера. |
public string $shop = ''; | Поле, или свойство, хранит настройку объекта. public разрешает читать и менять её снаружи; string — тип значения; пустая строка — начальное значение. |
$tea = new LabelPrinter(); | new создаёт объект по описанию класса; = сохраняет его в переменной. Скобки относятся к созданию экземпляра; настройки конструктора изучим отдельно. |
$tea->label('Чай') | Стрелка -> выбирает метод у конкретного объекта. Чай попадёт в параметр $product; $this->shop прочитает настройку этого же объекта. |
return $this->shop . ': ' . $product; | Получаем две строки, соединяем их с разделителем и возвращаем результат именно вызывающей строке. После echo выполнение продолжается со следующей команды. |
Это первая версия для понимания механизма: поле открыто и его легко забыть заполнить. Конструктор научит передавать обязательную настройку при создании, а видимость — запрещать некорректную смену состояния.
Что выиграли и чего объект пока не гарантирует
| Вопрос | Функция и настройки | Объект |
|---|---|---|
| Где общий код? | В одной функции label. | В одном методе LabelPrinter::label. |
| Где настройка магазина? | В переменной, передаём в каждый вызов. | В поле конкретного объекта; вызов передаёт только товар. |
| Можно ли использовать два магазина? | Да, передавать разные настройки. | Да, создать два экземпляра с разным состоянием. |
| Проверяет ли это права пользователя? | Нет, требуется отдельная проверка. | Нет, private/public не являются авторизацией. |
| Живёт ли настройка между HTTP-запросами? | Нет, если её не сохранить. | Сам объект тоже не переживает независимый запрос без отдельного механизма хранения. |
Выигрыш объекта — связать конкретное состояние и действия над ним, когда такая связь помогает пользоваться инструментом и поддерживать правила. Названия «сервис», «сущность», «объект-значение», «адаптер» описывают роли, а не новые команды PHP. К ним перейдём на примерах заказа после освоения class, new, свойств и методов.
Когда достаточно функции
<?php
declare(strict_types=1);
function priceLabel(int $rubles): string
{
return $rubles . ' руб.';
}
echo priceLabel(1290), "\n";
echo priceLabel(350), "\n";
Ожидаемый результат
1290 руб.
350 руб.
Функция зависит только от аргумента и не хранит настроек. Создавать класс только ради имени priceLabel необязательно. Здесь суммы целые рубли; расчёты дробных сумм и валют — другая задача.
Перенос идеи на клиент CRM
Клиент CRM хранит адрес конкретного портала и транспорт — инструмент выполнения HTTP-запросов. Тогда вызов $b24->listMethod($filters, "crm.deal.list") получает данные конкретного портала, а не просит передавать настройки заново при каждом методе. Такой удобный вызов мы соберём после конструкторов и композиции. Названия Bitrix24 и Telegram сами по себе не требуют ООП; осмысленна отдельная конфигурация клиента и явная замена транспорта в проверке.
Загрузка класса из Composer не авторизует клиента и не загружает все классы приложения заранее. Автозагрузчик подключает нужное определение по запросу; new создаёт объект; сетевой вызов выполняет только написанный для него метод. Не прячьте вход в API в конструкторе без конкретной причины.
Упражнение и следующий шаг
Попробуйте. Добавьте третий принтер для Пекарни и выведите Пекарня: Хлеб, не изменяя класс.
Разобрать решение
Создайте $bakery = new LabelPrinter(); задайте $bakery->shop = "Пекарня"; затем echo $bakery->label("Хлеб"). Третья настройка существует отдельно, а код метода всё ещё один.
В разделах про Money и состояние заказа ниже показаны старшие применения тех же идей. Их удобно читать после свойств и методов, конструкторов и обработки ошибок. Они не являются синтаксисом, который нужно выучить для первого объекта.
Пример: не перепутать сумму с произвольным числом
Применение после конструктора и readonly: сложим цену 129900 копеек и доставку 30000 копеек. Сумма должна сохранить валюту и отклонить сложение рублей с евро. Как конструктор проверяет исходные данные; что защищает readonly.
- $price содержит 129900 RUB; $delivery — 30000 RUB
- plus получает второй Money в $other
- Проверяются валюта и вместимость int
- new self возвращает 159900 RUB; исходные суммы сохраняются
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(public int $minor, public string $currency)
{
if ($minor < 0 || !in_array($currency, ['RUB', 'EUR'], true)) {
throw new InvalidArgumentException('Некорректная сумма');
}
}
public function plus(self $other): self
{
if ($this->currency !== $other->currency) {
throw new DomainException('Разные валюты');
}
if ($other->minor > PHP_INT_MAX - $this->minor) {
throw new OverflowException('Слишком большая сумма');
}
return new self($this->minor + $other->minor, $this->currency);
}
}
$price = new Money(129900, 'RUB');
$delivery = new Money(30000, 'RUB');
echo $price->plus($delivery)->minor, "\n";
try { $price->plus(new Money(100, 'EUR')); }
catch (DomainException $error) { echo $error->getMessage(), "\n"; }
Ожидаемый результат
159900
Разные валюты
Деньги здесь — не float: итог хранится в минимальных денежных единицах. Валюты не смешиваются, переполнение проверено. Это упрощённая модель для положительных сумм с двумя знаками; у валют бывают другие масштабы, а возврат и бухгалтерская проводка требуют отдельной модели.
Пример: метод вместо свободной записи статуса
Ещё одно применение: после отметки оплаты заказ должен показать paid, а повторная отметка должна быть отклонена. Это локальное правило объекта; реальный банк здесь не вызывается. Закрытое состояние и разрешённая операция объясняют private и метод markPaid, а проект магазина связывает это с результатом платёжного исполнителя.
<?php
declare(strict_types=1);
final class Order
{
private string $status = 'new';
public function __construct(public readonly string $id) {}
public function markPaid(): void
{
if ($this->status !== 'new') {
throw new DomainException('Переход запрещён');
}
$this->status = 'paid';
}
public function status(): string { return $this->status; }
}
$order = new Order('order-1042');
$order->markPaid();
echo $order->id, ':', $order->status(), "\n";
try { $order->markPaid(); }
catch (DomainException $error) { echo $error->getMessage(), "\n"; }
Ожидаемый результат
order-1042:paid
Переход запрещён
Метод защищает состояние одного объекта. Два параллельных запроса всё ещё могут прочитать один заказ как new: от двойной записи защищает транзакция, версия строки или уникальный ключ в БД. ООП само по себе не обеспечивает конкурентную безопасность.
- Контроллер проверяет вход и права
- Сервис загружает заказ
- Order проверяет бизнес-переход
- Репозиторий сохраняет с защитой от гонки
- Контроллер возвращает понятный HTTP-ответ
Почему старые примеры PHP ведут себя иначе: карта изменений ООП
Вы открыли старую статью и видите метод с именем класса вместо __construct или функцию __autoload. Не копируйте строку до выяснения версии: в современных PHP исторический приём может стать обычным методом, ошибкой или удалённой возможностью. Таблица ниже следует пунктам официального OOP Changelog; это история конкретного раздела, а не полный список всех изменений PHP.
Читайте её как маршрут диагностики: заметили конструкцию → нашли период изменения → перешли к современному объяснению → проверили минимальный пример на своей минимально поддерживаемой версии. Наши новые исполняемые опыты проверены на PHP 8.5.11; PHP 5 и 7 здесь не запускались.
- Найти необычную конструкцию в старом коде
- Проверить её историческую границу
- Выбрать действующий механизм из связанного урока
- Прогнать совместимость проекта на целевой версии
| Версия | Изменения модели ООП |
|---|---|
| 8.5 | static → self в final; порядок связывания трейтов |
| 8.4 | Property hooks; lazy objects |
| 8.1 | final константы; переопределяемые interface-константы |
| 8.0 | Nullsafe ?-> |
| 7.4 | Исключения в __toString; вариативность типов; типизированные свойства |
| 7.3 | Историческое ограничение unpack Traversable; статические ссылки; literal instanceof |
| 7.2 | __autoload устаревает; object зарезервирован; group-use запятая; расширение параметров; повторный abstract |
| 7.1 | void/iterable зарезервированы; видимость констант |
| 7.0 | Устаревают static-вызов обычного метода и именной конструктор; group-use; anonymous class; перебор объектов; совместимые trait-свойства |
| 5.6 | __debugInfo |
| 5.5 | ::class; finally |
| 5.4 | Трейты; контроль абстрактного конструктора |
| 5.3.3 | Именной конструктор в namespace больше не конструктор |
| 5.3 | Defaults интерфейса; переменная::константа; диагностика magic; перехват autoload-исключений; __callStatic; heredoc/nowdoc; late static binding; __invoke |
| 5.2 | __toString в строковых контекстах; диагностика преобразования |
| 5.1.3 | var перестал выдавать прежний E_STRICT |
| 5.1 | __set_state; __isset; __unset |
Для современного проекта из этой таблицы не следует разрешение использовать удалённое __autoload: с PHP 8.0 используйте spl_autoload_register или Composer. Метод с именем класса в PHP 8 не служит конструктором; нужен __construct. Обычный метод вызывается через экземпляр, а не статически. Var всё ещё историческая форма публичного свойства, но явное public понятнее читателю.
Строка 7.3 про ключи распаковки историческая: современные PHP 8 поддерживают именованные аргументы, поэтому нельзя превратить старый запрет нечисловых ключей в универсальное правило для сегодняшнего кода. Также не используйте старые уровни диагностики из таблицы как обещание текущего Error или warning.
Для практического продолжения: автозагрузка, конструктор, совместимость типов, обработка сериализации старых данных. При обновлении сервера проверьте и официальное руководство перехода для каждой пропущенной версии: оно шире ООП.
Упражнение: в библиотеке найдены __autoload и класс с методом, совпадающим с его именем. Решение: отдельно заменить загрузчик и проверить создание объекта после перехода на __construct. Простого исправления parse error недостаточно: старый код мог молча перестать инициализировать обязательные поля.