Глава 43 · От основ к архитектуре
Наследование: подстановка, переопределение и цена связности
Расширяем поведение, не нарушая обещаний базового типа. Практика LSP, parent, final, Override, ковариантности и контравариантности.
Самовывоз тоже является способом доставки
Страница заказа уже умеет показать стоимость доставки. Хотим добавить самовывоз и сохранить тот же способ вызова. Наследование подходит, если новый класс действительно может использоваться вместо старого по его договору.
Что получится. Один вывод покажет Курьер: 30000 коп., другой — Самовывоз: 0 коп.
- Delivery обещает стоимость целым числом копеек
- Pickup сохраняет обещание, выбирая бесплатную доставку
- Один вызывающий код обращается к обоим через costKopecks
- Выполняется метод конкретного переданного объекта
<?php
declare(strict_types=1);
class Delivery
{
public function label(): string { return 'Доставка заказа'; }
}
class Pickup extends Delivery {}
$pickup = new Pickup();
echo $pickup->label(), "\n";
Ожидаемый результат
Доставка заказа
extends связывает дочерний Pickup с родительским Delivery. В Pickup нет собственного label, поэтому используется доступный метод родителя. Пустые фигурные скобки здесь означают отсутствие добавленных членов, а не отсутствие унаследованного поведения.
| Запись в примере | Что делает каждый элемент |
|---|---|
class Pickup extends Delivery | Pickup — потомок; Delivery — единственный непосредственный родитель класса. |
$pickup->label() | Сначала ищется подходящая реализация в классе объекта, затем в наследовании. |
public / protected / private | Публичные и защищённые члены участвуют в доступном договоре наследования; private родителя недоступны прямому обращению потомка. |
Проверить «является разновидностью» работающим вызовом
Новый тариф подходит прежнему расчёту доставки
Функция preview принимает DeliveryPolicy. Потомок может менять тариф, сохраняя договор о входе и единицах результата.
- DeliveryPolicy
Базовый тариф: 30000 копеек за доставку.
- Вход: стоимость товаров
600000 копеек принимаются; отрицательная сумма отклоняется.
- Выход: 30000
preview получает целую сумму доставки в копейках.
- FreeAfterThreshold
Потомок даёт бесплатную доставку от 500000 копеек.
- parent сохраняет общий шаг
parent::feeMinor проверяет вход и получает базовый тариф.
- Выход зависит от суммы
600000 → 0; 100000 → 30000. Единицы и форма результата прежние.
Подстановка работает, пока потомок выполняет обещания базового типа. Совпадения имени метода недостаточно: неожиданный запрет допустимого входа сломает прежний вызывающий код.
<?php
declare(strict_types=1);
class Delivery
{
public function costKopecks(): int { return 30000; }
public function label(): string { return 'Курьер'; }
}
class Pickup extends Delivery
{
public function costKopecks(): int { return 0; }
public function label(): string { return 'Самовывоз'; }
}
function deliveryLine(Delivery $delivery): string
{
return $delivery->label() . ': ' . $delivery->costKopecks() . ' коп.';
}
echo deliveryLine(new Delivery()), "\n";
echo deliveryLine(new Pickup()), "\n";
Ожидаемый результат
Курьер: 30000 коп.
Самовывоз: 0 коп.
Параметр Delivery принимает и экземпляр потомка Pickup. Внутри функции не нужен if по имени класса: вызов попадает в реализацию самого объекта. Это подстановка, ради которой договор родителя нельзя неожиданно сужать.
Если Pickup начнёт бросать «стоимость недоступна» на любом вызове, формальная сигнатура останется, но обещание страницы нарушится. Если доставки изначально равноправные, без полезного общего устройства, интерфейс выразит договор яснее.
Переопределить одну операцию, сохранив её смысл
<?php
declare(strict_types=1);
class Delivery
{
public function costKopecks(): int { return 30000; }
public function label(): string { return 'Курьер'; }
}
class Pickup extends Delivery
{
public function costKopecks(): int { return 0; }
public function label(): string { return 'Самовывоз'; }
}
$courier = new Delivery();
$pickup = new Pickup();
echo $courier->label(), ': ', $courier->costKopecks(), " коп.\n";
echo $pickup->label(), ': ', $pickup->costKopecks(), " коп.\n";
Ожидаемый результат
Курьер: 30000 коп.
Самовывоз: 0 коп.
Одинаковое имя метода в потомке задаёт его реализацию. Возвращаемый тип остался int, единицы — копейки, бесплатная доставка допустима. Если нужно дополнить, а не заменить поведение, parent::label() может явно вызвать родительский метод.
Совместимость: не требовать больше, чем обещал родитель
Сигнатура — имя, параметры, их типы и возвращаемый тип метода. Потомок не может сделать обязательным новый аргумент, запретить ранее допустимый вход или сузить public до protected. Он может принимать более общий тип входа и возвращать более конкретный тип результата. Эти направления называют контравариантностью параметра и ковариантностью результата.
<?php
declare(strict_types=1);
class Delivery
{
public function label(): string { return 'Доставка'; }
}
class Pickup extends Delivery
{
public function label(): string { return 'Самовывоз'; }
}
class DeliveryFactory
{
public function create(): Delivery { return new Delivery(); }
}
class PickupFactory extends DeliveryFactory
{
public function create(): Pickup { return new Pickup(); }
}
function showDelivery(DeliveryFactory $factory): void
{
echo $factory->create()->label(), "\n";
}
showDelivery(new PickupFactory());
Ожидаемый результат
Самовывоз
Вызывающему обещана Delivery; Pickup полностью удовлетворяет этому обещанию. Поэтому результат разрешено уточнить. Обратная замена Pickup на произвольную Delivery могла бы лишить вызывающего нужных возможностей.
Имена параметров тоже держите согласованными: вызывающий может использовать именованные аргументы. У обычного читаемого и записываемого свойства тип, как правило, инвариантен; специальные правила get-only/set-only разобраны ниже. Для внутренних классов учитывайте их объявленные и tentative-типы; ReturnTypeWillChange — временная совместимость, а не исправление ошибочного договора.
Пусть PHP проверит намерение переопределить — PHP 8.3+
<?php
declare(strict_types=1);
class Delivery
{
public function label(): string { return 'Доставка'; }
}
class Pickup extends Delivery
{
#[Override]
public function label(): string { return 'Самовывоз'; }
}
echo (new Pickup())->label(), "\n";
Ожидаемый результат
Самовывоз
#[Override] — встроенный атрибут: PHP проверяет, что метод действительно переопределяет родительский или реализует интерфейсный метод. При случайном имени lable загрузка объявления завершится ошибкой. Сам атрибут не меняет алгоритм. PHP 8.5 расширяет применение Override на свойства; детали — в справочнике атрибутов.
Клиент CRM использует HTTP-клиент
Bitrix24Client не является разновидностью универсального HTTP-клиента: он знает методы CRM, поля запроса и форму ответа. HTTP-клиент умеет отправлять запросы. Эти обязанности связываются композицией: Bitrix24Client получает транспорт и вызывает его. Унаследовать библиотечный Client ради готового request часто означает открыть пользователю лишние операции и связаться с устройством библиотеки.
Для Guzzle обычно передают объект с ClientInterface в собственный адаптер. Проверяйте договор конкретной установленной версии; класс, объявленный final, расширять нельзя. Ни один наш пример не требует наследования от final Client. Практический код с заменой транспорта будет в композиции и DI.
Попробуйте. Добавьте ExpressDelivery с ценой 50000 коп. и подписью Экспресс. Передайте его в deliveryLine.
Разобрать решение
Переопределите два публичных метода, сохранив типы и единицы. В deliveryLine не добавляйте условие: изменение должно оставаться в новом исполнителе.
Явно закрыть расширение там, где оно не предусмотрено
final class запрещает потомков; final method запрещает переопределение конкретного метода. Это способ сохранить договор проверенного алгоритма, а не средство шифрования. У private-метода обычного класса уже нет внешнего договора переопределения; специальное значение сохраняет final private-конструктор. final-свойства поддерживаются с PHP 8.4 и не означают readonly: запрет переопределения отличается от запрета изменения значения.
Не стройте глубокую иерархию из-за одного общего поля. Один родитель допустим у класса, несколько интерфейсов — допустимы. Смешение readonly и обычного свойства при переопределении запрещено. Если возможность должна независимо заменяться, передайте её как объект.
Дальше: полный договор языка и специальные случаи
Ниже сохранены точные правила вариативности свойств, final и static-результата. Они нужны при проектировании библиотечного договора; начинать наследование стоит с проверки подстановки, как у Delivery.
Разделы ниже сохраняют специальные правила официального PHP Manual: Почему читаемый документ можно уточнить до счёта — PHP 8.4+, Final запрещает подмену объявления, readonly — изменение значения, Договор только записи разрешает принимать более широкий тип — PHP 8.4+, PHP 8.5: почему final разрешает заменить static на self.
Почему читаемый документ можно уточнить до счёта — PHP 8.4+
В вариантности методов результат разрешено уточнять. Для свойства до PHP 8.4 правило обычно было строже: в одну ячейку и пишут, и читают. Требование только get меняет ситуацию: пользователю обещан Document, значит Invoice тоже подходит. Но право записать любой Document уже нельзя совместить с хранением только Invoice.
- HasDocument обещает Document
- InvoiceCard хранит его потомка Invoice
- Читатель получает обещанный документ и номер INV-42
- Права записать любой Document договор не выдавал
<?php
declare(strict_types=1);
class Document
{
public function __construct(public string $number) {}
}
class Invoice extends Document {}
interface HasDocument
{
public Document $document { get; }
}
class InvoiceCard implements HasDocument
{
public function __construct(public Invoice $document) {}
}
function printNumber(HasDocument $card): void
{
echo $card->document->number, "\n";
}
printNumber(new InvoiceCard(new Invoice('INV-42')));
Ожидаемый результат
INV-42
Читающий код требует Document и получает его допустимого потомка Invoice. Само поле InvoiceCard остаётся и читаемым, и записываемым, поэтому дальше в его наследниках тип этого поля нельзя свободно менять. Особое правило относится к удовлетворению get-only договора.
| Доступ по договору | Направление изменения типа | Зачем |
|---|---|---|
| Только get | Разрешено сузить результат: Document → Invoice | Читатель всё ещё получает обещанный Document. |
| Только set | Разрешено расширить вход: Invoice → Document | Принимаются все обещанные счета и дополнительно другие документы. |
| И get, и set | Тип сохраняется: инвариантность | Одновременно нужно безопасное чтение и безопасная запись. |
Эти правила относятся к поддерживаемым интерфейсным, абстрактным и виртуальным свойствам. Не заменяйте ими проверку совместимости любого обычного поля. Защищённое хранимое свойство родителя нельзя просто переобъявить с более удобным вам типом.
Final запрещает подмену объявления, readonly — изменение значения
При чтении final легко предположить, что значение поля нельзя менять. На деле речь о переопределении в наследнике. Сначала вспомните родителя и потомка; затем сравним это с readonly-свойством.
- Родитель закрепил final-поле format
- Потомок наследует это объявление
- Разрешённая запись меняет csv на json
- Повторно объявить format в потомке нельзя
<?php
declare(strict_types=1);
class ReportConfiguration
{
final public string $format = 'csv';
}
class LocalReportConfiguration extends ReportConfiguration {}
$settings = new LocalReportConfiguration();
$settings->format = 'json';
echo $settings->format, "\n";
$property = new ReflectionProperty(ReportConfiguration::class, 'format');
echo $property->isFinal() ? "Объявление защищено от переопределения\n" : "Объявление открыто\n";
Ожидаемый результат
json
Объявление защищено от переопределения
Значение поменялось обычной разрешённой записью. Попытка объявить format заново в LocalReportConfiguration была бы ошибкой совместимости при составлении класса. Reflection здесь лишь подтверждает флаг объявления и не меняет объект.
| Ключевое слово | Какую границу задаёт |
|---|---|
| final class | Нельзя создать потомка этого класса. |
| final method | Потомок не подменяет реализацию метода. |
| final const | Потомок не переопределяет константу; поддерживается с PHP 8.1. |
| final property | Потомок не переопределяет свойство; поддерживается с PHP 8.4. |
| readonly property | Значение подчиняется ограничениям инициализации и изменения, а не только наследования. |
Private(set)-свойство автоматически final; это отдельное следствие асимметричной видимости. Не делайте final каждую деталь механически: библиотека должна осознанно решить, какие точки расширения поддерживает. Для замены внешнего провайдера интерфейс и композиция часто сохраняют больше свободы, чем наследование конкретного клиента.
Договор только записи разрешает принимать более широкий тип — PHP 8.4+
Получатель счетов обязан принять Invoice. Конкретное хранилище умеет принимать любой Document, включая Invoice. Это безопасное расширение входа: ни один обещанный счёт не потерял возможность попасть в хранилище. Для читаемого поля направление было обратным: уточнение результата get.
- Интерфейс обещает принять Invoice
- Хранилище принимает Document
- Каждый Invoice уже является Document
- Потребитель договора может передать счёт без знания расширения
<?php
declare(strict_types=1);
class StoredDocument
{
public function __construct(public string $number) {}
}
class StoredInvoice extends StoredDocument {}
interface AcceptsInvoice
{
public StoredInvoice $document { set; }
}
class DocumentInbox implements AcceptsInvoice
{
private ?StoredDocument $last = null;
public StoredDocument $document {
set { $this->last = $value; }
}
public function lastNumber(): string { return $this->last?->number ?? 'Пусто'; }
}
function placeInvoice(AcceptsInvoice $inbox, StoredInvoice $invoice): void
{
$inbox->document = $invoice;
}
$inbox = new DocumentInbox();
placeInvoice($inbox, new StoredInvoice('INV-42'));
echo $inbox->lastNumber(), "\n";
$inbox->document = new StoredDocument('DOC-7');
echo $inbox->lastNumber(), "\n";
Ожидаемый результат
INV-42
DOC-7
Интерфейс не обещает чтение document. Виртуальный set реализации сохраняет принятый документ в отдельном last. Вход расширен с StoredInvoice до StoredDocument; сам договор AcceptsInvoice по-прежнему описывает только обязательную возможность принять счёт.
Если тот же интерфейс обещал бы get, возвращать произвольный Document вместо Invoice было бы недостаточно. Поэтому для одновременно читаемого и записываемого обычного свойства тип инвариантен. Это проверка совместимости контракта, а не подсказка превратить все сущности в write-only поля.
На практике accept(Invoice $invoice) часто яснее, чем присваивание: имя метода показывает действие и позволяет вернуть результат. Свойство с set уместно там, где присваивание действительно ожидаемая модель API.
PHP 8.5: почему final разрешает заменить static на self
Базовый отчёт обещает, что фабрика вернёт экземпляр вызванного класса. Обычно self в переопределении был бы слабее: следующий потомок мог бы получить объект своего родителя. Но final закрывает возможность следующего потомка. В таком классе self, собственное имя класса и static обозначают один и тот же возможный тип результата.
Это уточнение совместимости с PHP 8.5. Сначала разберите позднее связывание. В первой строке new static выбирает вызванный класс; во второй реализации new self относится к конкретному final-классу.
- BaseReport::create обещает static
- FinalCsvReport невозможно расширить
- В его create тип self не допускает более общего результата
- Создан FinalCsvReport, договор базового класса выполнен
<?php
declare(strict_types=1);
class FactoryReport
{
public static function create(): static { return new static(); }
}
final class FinalCsvReport extends FactoryReport
{
public static function create(): self { return new self(); }
}
final class FinalJsonReport extends FactoryReport
{
public static function create(): FinalJsonReport { return new FinalJsonReport(); }
}
echo get_class(FinalCsvReport::create()), "\n";
echo get_class(FinalJsonReport::create()), "\n";
Ожидаемый результат
FinalCsvReport
FinalJsonReport
На PHP 8.4 это переопределение ещё нельзя считать совместимым. Если библиотека поддерживает 8.2–8.4, сохраните static в сигнатуре. Уберите final — и равенство возможных типов перестанет быть гарантировано; новая возможность 8.5 на такой класс не распространяется.
Упражнение: представьте CsvForPartner extends FinalCsvReport. Решение: объявление запрещено самим final. Именно эта невозможность исключает случай, когда self обозначал бы родителя вызывающего класса. Не добавляйте final ради обхода ошибки типов, если расширение — часть публичного договора вашей библиотеки.
Функции и методы этой темы
Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.
instanceofПроверить объект на класс, предка или интерфейс.
$object instanceof Type: boolЭто оператор языка. Он возвращает true для экземпляра указанного класса, его потомка или реализации интерфейса. Для обычного не-объекта слева результат false.
<?php
declare(strict_types=1);
interface Refundable { public function refund(): string; }
final class DemoGateway implements Refundable { public function refund(): string { return 'Возврат поддержан'; } }
$gateway = new DemoGateway();
if ($gateway instanceof Refundable) { echo $gateway->refund(), "\n"; }
Ожидаемый результат
Возврат поддержан
Проверяется возможность, а не конкретное имя провайдера.
<?php
declare(strict_types=1);
final class OrderLine {}
$items = [new OrderLine(), 'not-a-line'];
foreach ($items as $item) { echo $item instanceof OrderLine ? "Строка заказа\n" : "Неверный тип\n"; }
Ожидаемый результат
Строка заказа
Неверный тип
Тип array не гарантирует тип каждого элемента. Валидация коллекции остаётся необходимой.
Рядом: is_subclass_of, get_class.
is_subclass_ofПроверить наследование или реализацию интерфейса, исключив совпадение с самим исходным типом.
is_subclass_of(mixed $object_or_class, string $class, bool $allow_string = true): boolПроверить наследование или реализацию интерфейса, исключив совпадение с самим исходным типом.
- Importer — базовый класс
- CsvImporter extends Importer — отдельный потомок
- is_subclass_of(CsvImporter, Importer) true; Importer относительно себя false
| Вход / результат | Точный смысл |
|---|---|
| $object_or_class | Объект или имя проверяемого класса; непригодные значения дают false. |
| $class | Класс-предок либо интерфейс; сам этот же тип не считается своим подклассом. Учитывается непрямая цепочка. |
| $allow_string | Здесь default=true, в отличие от is_a. false запрещает строковый первый аргумент и его автозагрузку. |
| Результат / ошибки | Логическое отношение типов, включая implements. Отсутствующее имя обычно false, но ошибка autoloader может прервать проверку. |
Название функции не ограничивает её прямым extends: реализация интерфейса тоже подходит. Для всего набора «сам базовый класс плюс потомки» используйте is_a.
<?php
declare(strict_types=1);
class Importer {}
class CsvImporter extends Importer {}
var_dump(is_subclass_of(Importer::class, Importer::class));
var_dump(is_subclass_of(CsvImporter::class, Importer::class));
var_dump(is_a(Importer::class, Importer::class, true));
Ожидаемый результат
bool(false)
bool(true)
bool(true)
Отличие имеет значение, если базовый класс должен быть исключён из списка подключаемых реализаций.
<?php
declare(strict_types=1);
interface Exporter { public function format(): string; }
class CsvExporter implements Exporter
{
public function format(): string { return 'csv'; }
}
class Settings {}
$candidates = [CsvExporter::class, Settings::class];
foreach ($candidates as $name) {
if (is_subclass_of($name, Exporter::class)) {
echo $name, "\n";
}
}
Ожидаемый результат
CsvExporter
foreach рассматривает каждое имя. Здесь мы только отбираем структурно совместимые классы; конструкторы не вызываются. В рабочей фабрике отдельно задают зависимости и способ создания, а ReflectionClass::isInstantiable помогает выявить абстрактный класс или недоступный конструктор.
Не используйте проверку для выбора между десятками if по конкретным подклассам: интерфейс и обычный вызов метода обычно позволяют расширить систему без такого списка. Для отображения самого дерева подойдёт get_parent_class, для полного набора предков — SPL class_parents.
Рядом: is_a, get_parent_class, interface_exists. Как выбрать проверку.
Дополнительные примеры из практической главы
Проверяет отношение к родительскому классу или интерфейсу. Тот же самый класс сам по себе не считается своим потомком. При разрешённом строковом имени может участвовать автозагрузка.
<?php
declare(strict_types=1);
interface Job { public function run(): void; }
final class ImportJob implements Job { public function run(): void {} }
echo is_subclass_of(ImportJob::class, Job::class) ? "Подходит\n" : "Нет\n";
Ожидаемый результат
Подходит
Так валидируют заранее разрешённую карту обработчиков, а не произвольный класс от пользователя.
<?php
declare(strict_types=1);
class Receipt {}
$receipt = new Receipt();
echo is_subclass_of($receipt, Receipt::class) ? "Потомок\n" : "Не потомок\n";
echo $receipt instanceof Receipt ? "Подходящий тип\n" : "Нет\n";
Ожидаемый результат
Не потомок
Подходящий тип
Выбирайте проверку по задаче, иначе можно случайно исключить сам базовый тип.
Рядом: instanceof, class_implements.