Типы и обработка ошибок
Система типов и модель ошибок PHP — ошибки против исключений, иерархия Throwable, семантика try/finally, объявления union- и intersection-типов, приведение типов при strict_types, атрибуты и их чтение через Reflection, а также собственные обработчики ошибок.
17 вопросов
JuniorТеорияОчень частоЧем warning отличается от fatal error и от исключения в PHP?
Чем warning отличается от fatal error и от исключения в PHP?
Warning или notice — это диагностика: движок сообщает о ней, а выполнение идёт дальше. Fatal error останавливает скрипт целиком. Исключение — это объект, который вы бросаете через throw; оно разматывает стек до подходящего catch, а если такого нет, скрипт падает с fatal error.
Типичные ошибки
- ✗Считать, что
catch (Exception $e)перехватит warning или notice - ✗Полагать, что warning останавливает выполнение так же, как fatal error
- ✗Ждать, что непойманное исключение будет молча проигнорировано, а не фатально
Уточняющие вопросы
- →Какие сбои PHP 7 превратил в бросаемые объекты
Error, а какие остались фатальными? - →Как сделать warning видимыми в разработке, но тихо логируемыми в проде?
JuniorТеорияЧастоЧто такое атрибут в PHP и как код читает его в рантайме?
Что такое атрибут в PHP и как код читает его в рантайме?
Атрибут — это структурированные метаданные вида #[Route('/users')] над классом, методом, свойством или параметром. Движок разбирает их и на этом останавливается: читают их через Reflection — getAttributes() возвращает объявления, а newInstance() создаёт объект атрибута. Класс атрибута несёт #[Attribute].
Типичные ошибки
- ✗Ждать, что атрибут что-то сделает сам, без читателя, вызывающего Reflection
- ✗Считать атрибут комментарием, который движок выбрасывает, как docblock
- ✗Полагать, что атрибуты можно вешать только на класс, но не на метод или свойство
Уточняющие вопросы
- →Что даёт
newInstance()сверх сырых аргументов атрибута? - →Когда класс, названный в атрибуте, на самом деле автозагружается?
JuniorТеорияЧастоЧем ?int отличается от union-типа, включающего null?
Чем ?int отличается от union-типа, включающего null?
?int — сокращение для int|null: тот же тип, та же проверка в рантайме. Разница лишь в охвате: ? ставится ровно перед одним типом, поэтому nullable-union из двух типов приходится писать как int|string|null. Формы не смешиваются, и ?int|string — ошибка разбора.
Типичные ошибки
- ✗Путать nullable-тип с необязательным параметром, который можно опустить
- ✗Считать, что
?intпринимает любое ложное значение, а не строгоintилиnull - ✗Пытаться написать
?int|stringвместоint|string|null
Уточняющие вопросы
- →Нужно ли передавать параметр
?intбез значения по умолчанию в месте вызова? - →Что nullable-тип возврата говорит о функции, которая ничего не нашла?
JuniorТеорияЧастоКак связаны Throwable, Error и Exception в PHP?
Как связаны Throwable, Error и Exception в PHP?
Throwable — интерфейс, который реализует всё бросаемое. Под ним две отдельные ветви: Exception — прикладные сбои, которые вы обязаны обработать, и Error — сбои движка вроде TypeError. Поэтому catch (Exception $e) никогда не ловит Error — для обоих ловите Throwable.
Типичные ошибки
- ✗Ждать, что
catch (Exception $e)подберётTypeErrorили другойError - ✗Считать, что
Errorнаследуется отException, а не стоит в параллельной ветви - ✗Принимать
Throwableза класс, от которого наследуются, а не за интерфейс
Уточняющие вопросы
- →Когда ловить
\Throwableправильно, а когда это слишком широко? - →Почему пользовательский код не может реализовать
Throwableна произвольном классе?
JuniorТеорияЧастоЧто разрешает и что отвергает объявление union-типа вида int|string?
Что разрешает и что отвергает объявление union-типа вида int|string?
Union принимает значение, подходящее под любой один из перечисленных типов, а остальное отвергает с TypeError на границе. int|string берёт int или строку, но не null — чтобы разрешить null, пишут int|string|null. Проверка идёт в месте вызова, при каждом вызове, в рантайме.
Типичные ошибки
- ✗Считать union неявно nullable и принимающим
nullбез явного перечисления - ✗Думать, что union — подсказка, которую движок в рантайме не проверяет
- ✗Читать
|как пересечение — значение, удовлетворяющее сразу всем типам
Уточняющие вопросы
- →Почему
voidиneverне могут стоять внутри union-типа? - →Как
strict_typesменяет то, какие значения unionint|stringна деле принимает?
MiddleТеорияЧастоКакие сбои стали перехватываемыми в PHP 7, а какие остаются неперехватываемым fatal error?
Какие сбои стали перехватываемыми в PHP 7, а какие остаются неперехватываемым fatal error?
PHP 7 превратил большинство сбоев движка в брошенные объекты Error — вызов метода на null, TypeError, DivisionByZeroError, — поэтому catch (\Error) или catch (\Throwable) их обрабатывает. По-настоящему фатальным осталось то, откуда движок продолжить не может: исчерпание памяти, лимит времени, ошибка разбора.
Типичные ошибки
- ✗Считать, что
catch (Exception $e)подберётErrorот движка - ✗Ждать, что исчерпание памяти или превышение лимита времени можно поймать
- ✗Считать fatal error и брошенный
Errorодним и тем же
Уточняющие вопросы
- →Как всё же сообщить логгеру о fatal error, который ничем не поймать?
- →Почему ошибка разбора во включённом файле никогда не доходит до вашего
try/catch?
MiddleДизайнЧастоВы ведёте модуль платежей. Сейчас он бросает обычное \Exception со строкой сообщения из любой ветки сбоя — отклонённая карта, недоступный шлюз, некорректная сумма, повторное списание, — а вызывающий код пишет catch (\Exception $e) и разбирает текст сообщения через str_contains(). Нужно спроектировать нормальную иерархию исключений модуля. Опишите, на чём вы её построите, какие сбои заслуживают собственного класса, а какие нет, что вызывающий должен уметь поймать, не зная внутренностей модуля, как нести структурные данные вроде кода отказа шлюза и как позже добавлять новые типы сбоев, не ломая этих вызывающих.
Вы ведёте модуль платежей. Сейчас он бросает обычное \Exception со строкой сообщения из любой ветки сбоя — отклонённая карта, недоступный шлюз, некорректная сумма, повторное списание, — а вызывающий код пишет catch (\Exception $e) и разбирает текст сообщения через str_contains(). Нужно спроектировать нормальную иерархию исключений модуля. Опишите, на чём вы её построите, какие сбои заслуживают собственного класса, а какие нет, что вызывающий должен уметь поймать, не зная внутренностей модуля, как нести структурные данные вроде кода отказа шлюза и как позже добавлять новые типы сбоев, не ломая этих вызывающих.
Стройте иерархию на том, как обязан ОТРЕАГИРОВАТЬ вызывающий, а не на том, где возник сбой. Опубликуйте одно базовое исключение (или интерфейс-метку), которое бросает весь модуль, чтобы вызывающий ловил модуль, не зная его. Разделяйте лишь там, где различается обработка. Код отказа несите типизированным свойством, а не внутри сообщения.
Типичные ошибки
- ✗Строить иерархию вокруг того, где возник сбой, а не как обязан отреагировать вызывающий
- ✗Класть структурные данные вроде кода отказа внутрь строки сообщения
- ✗Не давать общего базового класса, из-за чего вызывающему приходится ловить
\Throwable
Уточняющие вопросы
- →Что сломается у вызывающего, если вставить новый класс в середину иерархии?
- →Когда интерфейс-метка — лучший публичный контракт, чем базовый класс исключения?
MiddleТеорияЧастоГде именно declare(strict_types=1) меняет поведение и чей файл это решает?
Где именно declare(strict_types=1) меняет поведение и чей файл это решает?
Он меняет только скалярные объявления типов, действует по файлу, и решает тот файл, который делает ВЫЗОВ, а не тот, что объявляет функцию. Строгий файл, зовущий нестрогую библиотеку, проверяется строго; нестрогий вызывающий вашей строгой библиотеки всё равно получает приведение.
Типичные ошибки
- ✗Считать, что
strict_typesобъявляющего файла управляет вызывающим - ✗Ждать, что
strict_typesвлияет на классовые,array- илиiterable-объявления - ✗Полагать, что директива глобальна для запроса, а не действует по файлу
Уточняющие вопросы
- →Почему
declare(strict_types=1)обязан быть самым первым оператором файла? - →Что происходит с внутренней функцией вроде
strlen(), когда вызывающий файл строгий?
JuniorТеорияИногдаЧто требует от значения intersection-тип вида Countable&Iterator?
Что требует от значения intersection-тип вида Countable&Iterator?
Intersection принимает только значение, удовлетворяющее сразу всем перечисленным типам, — объект, реализующий и Countable, и Iterator. В нём допустимы лишь имена классов и интерфейсов; скаляры, null и array отвергаются, поэтому int&string — ошибка компиляции. Появился в PHP 8.1.
Типичные ошибки
- ✗Читать
&как union — значение, подходящее под любой один из типов - ✗Пытаться поместить в intersection скаляры вроде
int&string - ✗Ждать, что intersection примет
nullбез явной nullable-формы
Уточняющие вопросы
- →Почему
?Countable&Iterator— недопустимое объявление в PHP 8.1? - →Когда intersection-тип — лучший контракт, чем объявление нового интерфейса?
JuniorТеорияИногдаЧто объявляет о функции тип возврата never?
Что объявляет о функции тип возврата never?
never говорит, что функция никогда не возвращает управление вызывающему: она либо бросает, либо зовёт exit(), либо крутится вечно. Это не void — функция с void возвращается, просто без значения. Анализаторы считают код после такого вызова недостижимым, а return внутри never-функции — ошибка.
Типичные ошибки
- ✗Считать
neverсинонимомvoid - ✗Писать
returnвнутри функции, объявленной какnever - ✗Ждать, что код после вызова
never-функции всё ещё достижим
Уточняющие вопросы
- →Чем
neverполезен на функции, единственная работа которой — бросить доменное исключение? - →Как объявление
neverменяет выводы статического анализатора о вызывающем коде?
MiddleТеорияИногдаЗачем PHP добавил атрибуты, если docblock-аннотации уже существовали?
Зачем PHP добавил атрибуты, если docblock-аннотации уже существовали?
Docblock — это комментарий: движок его выбрасывает, поэтому каждый фреймворк перечитывал исходный текст своим регулярным выражением, а опечатка молча падала. Атрибут — настоящий синтаксис: его разбирает движок, разрешает как имя класса, автозагружает и выдаёт через Reflection. Опечатка в имени класса атрибута — находимая ошибка.
Типичные ошибки
- ✗Считать, что docblock-аннотацию разбирает движок, а не регулярное выражение библиотеки
- ✗Ждать, что атрибут выполнится сам при загрузке класса
- ✗Полагать, что опечатка в классе атрибута падает так же молча, как опечатка в аннотации
Уточняющие вопросы
- →Что будет, если класса, названного в атрибуте, не существует, и когда вы об этом узнаете?
- →Что проверяет
newInstance()из того, что не проверяет чтение сырых аргументов атрибута?
MiddleТеорияИногдаЧто делает finally, если в блоке try уже выполнен return?
Что делает finally, если в блоке try уже выполнен return?
Возвращаемое значение вычисляется и откладывается, затем выполняется finally, и лишь потом функция возвращает. Поэтому finally выполняется всегда: и при обычном return, и при исключении, и при break. Если finally сам делает return, его значение перекрывает значение из try, а исключение из try теряется.
Типичные ошибки
- ✗Считать, что
returnвtryпропускаетfinally - ✗Не знать, что
returnвнутриfinallyперекрывает значение, возвращённое изtry - ✗Забывать, что возврат из
finallyпроглатывает исключение, брошенное вtry
Уточняющие вопросы
- →Что вернётся, если
returnесть и вtry, и вfinally? - →Почему
returnвнутриfinallyсчитают дурным запахом кода?
MiddleТеорияИногдаЧто перехватывает set_error_handler() и зачем превращать эти ошибки в исключения?
Что перехватывает set_error_handler() и зачем превращать эти ошибки в исключения?
set_error_handler() перехватывает диагностику движка — warning и notice, — но не исключения и не fatal error, от которого восстановиться нельзя. Обработчик обычно перебрасывает их как ErrorException, чтобы один try/catch покрывал оба мира и warning нельзя было проигнорировать.
Типичные ошибки
- ✗Ждать, что
set_error_handler()поймает fatal error - ✗Путать его с
set_exception_handler(), который занимается непойманными исключениями - ✗Регистрировать его поздно, после кода, который уже дал warning
Уточняющие вопросы
- →Что даёт
register_shutdown_function()при fatal error, который не поймать обработчиком? - →Что несёт
ErrorExceptionсверх обычного текста warning?
SeniorТеорияИногдаЧто переопределяющий метод вправе изменить в типах параметров и возврата и почему?
Что переопределяющий метод вправе изменить в типах параметров и возврата и почему?
PHP допускает ковариантные типы возврата и контравариантные типы параметров, и оба проверяются движком на этапе компиляции. Переопределение вправе сузить возврат — вернуть Dog там, где родитель возвращал Animal, — и расширить параметр, принимая Animal там, где родитель брал Dog. Правило одно: подстановка Лисков.
Типичные ошибки
- ✗Менять направления местами — сужать параметр или расширять тип возврата
- ✗Считать, что движок проверяет лишь точное совпадение двух сигнатур
- ✗Полагать, что правила вариантности рекомендательны и проверяются только анализаторами
Уточняющие вопросы
- →Что сломается у вызывающего, если производный класс сузит тип параметра?
- →Как тип возврата
staticвзаимодействует с правилом ковариантности?
MiddleТеорияРедкоЧто делает атрибут #[\NoDiscard] из PHP 8.5, если возвращённое значение проигнорировано?
Что делает атрибут #[\NoDiscard] из PHP 8.5, если возвращённое значение проигнорировано?
#[\NoDiscard] помечает функцию, чей возвращаемый результат обязан быть использован. Вызов с выброшенным результатом поднимает Warning в рантайме, и забытый результат перестаёт быть незаметным. Когда отбросить его намеренно, новое приведение (void) глушит предупреждение: (void) $conn->close();.
Типичные ошибки
- ✗Ждать, что отброшенный результат станет фатальной ошибкой, а не Warning
- ✗Глушить его оператором
@вместо приведения(void) - ✗Считать атрибут инертным в рантайме и читаемым лишь статическими анализаторами
Уточняющие вопросы
- →Что приведение
(void)делает со значением, к которому применено? - →Почему Warning, а не
TypeError, — верный уровень диагностики для отброшенного результата?
SeniorДизайнРедкоПриложение на Laravel не логирует ничего полезного. Каждый контроллер оборачивает тело в try/catch (\Exception $e), печатает сообщение и возвращает 200 с JSON-телом error. Поддержка не может связать жалобу пользователя со строкой лога, один и тот же сбой ловится и оформляется по-разному в восьми местах, а Error от движка вроде вызова метода на null ускользает из всех этих блоков и даёт белый экран. Спроектируйте централизованную обработку исключений и логирование: где должна жить обработка, что контроллер всё-таки ловит сам, что несёт ответ, что обязана содержать строка лога, чтобы быть полезной поддержке, и как не дать неожиданному сбою движка дойти до пользователя белым экраном.
Приложение на Laravel не логирует ничего полезного. Каждый контроллер оборачивает тело в try/catch (\Exception $e), печатает сообщение и возвращает 200 с JSON-телом error. Поддержка не может связать жалобу пользователя со строкой лога, один и тот же сбой ловится и оформляется по-разному в восьми местах, а Error от движка вроде вызова метода на null ускользает из всех этих блоков и даёт белый экран. Спроектируйте централизованную обработку исключений и логирование: где должна жить обработка, что контроллер всё-таки ловит сам, что несёт ответ, что обязана содержать строка лога, чтобы быть полезной поддержке, и как не дать неожиданному сбою движка дойти до пользователя белым экраном.
Обрабатывайте один раз, на границе фреймворка: единственный обработчик, ловящий \Throwable, — тогда Error от движка тоже покрыт. Контроллер ловит лишь то, от чего способен восстановиться. Обработчик сопоставляет исключению код статуса и устойчивую форму ошибки, добавляет корреляционный id, который же возвращает пользователю, и пишет его в лог со стеком и контекстом.
Типичные ошибки
- ✗Ловить на границе
\Exception, из-за чегоErrorот движка всё равно ускользает - ✗Отдавать клиенту сырое сообщение исключения или стек вызовов
- ✗Логировать без корреляционного id, который пользователь мог бы назвать поддержке
Уточняющие вопросы
- →Почему обработчик на границе обязан ловить
\Throwable, а не\Exception? - →Что должно быть в строке лога, но никогда не должно попадать в тело ответа?
SeniorДизайнРедкоВы хотите ввести declare(strict_types=1) в легаси-базе из 400 файлов, где объявления типов есть примерно у трети функций, а числовые строки ходят свободно: данные формы, колонки базы, читаемые строками, идентификаторы, которые то int, то '42'. Добавить директиву во все файлы разом — значит превратить молчаливые приведения в TypeError прямо в проде. Опишите безопасный выкат строгих типов: в каком порядке вы будете переводить файлы, учитывая, что директива действует по файлу и решает место вызова; как найти приведения, которые начнут падать, до того как они дойдут до прода; что делать с функцией, которая законно принимает и int, и string; и что не даст базе сползти обратно.
Вы хотите ввести declare(strict_types=1) в легаси-базе из 400 файлов, где объявления типов есть примерно у трети функций, а числовые строки ходят свободно: данные формы, колонки базы, читаемые строками, идентификаторы, которые то int, то '42'. Добавить директиву во все файлы разом — значит превратить молчаливые приведения в TypeError прямо в проде. Опишите безопасный выкат строгих типов: в каком порядке вы будете переводить файлы, учитывая, что директива действует по файлу и решает место вызова; как найти приведения, которые начнут падать, до того как они дойдут до прода; что делать с функцией, которая законно принимает и int, и string; и что не даст базе сползти обратно.
Выкатывайте по файлу, начиная с ЛИСТЬЕВ: решает файл, который делает ВЫЗОВ, поэтому директива в листе, который сам почти ничего не зовёт, почти ничего и не превратит в TypeError. Приведения, которые начнут падать, ищите до прода статическим анализом и прогоном тестов в строгом режиме. Там, где функция честно берёт оба типа, объявляйте int|string и нормализуйте внутри.
Типичные ошибки
- ✗Считать, что директива в файле точки входа каскадом уходит во включаемые файлы
- ✗Начинать с вершины графа вызовов, где новый строгий файл зовёт больше всего кода
- ✗Снимать объявление типа, чтобы уйти от
TypeError, вместо объявления union и нормализации
Уточняющие вопросы
- →Какой файл решает, приводить ли аргумент, — вызывающий или вызываемый?
- →Какая проверка в CI не даст новому файлу уехать без директивы?