Правила проектирования¶
Правила проектирования анализируют внутреннюю структуру ваших классов -- насколько они сфокусированы, как используется наследование и не взяли ли классы на себя слишком много ответственности. Эти правила помогают обнаружить структурные проблемы до того, как их исправление станет дорогим.
NOC -- Количество дочерних классов¶
Rule ID: design.noc
Судимая метрика: design.noc
Что измеряет¶
NOC считает, сколько классов напрямую наследуют (расширяют) данный класс.
Например, если 12 классов пишут extends BaseRepository, то у BaseRepository NOC = 12.
Как читать значение:
| NOC | Интерпретация |
|---|---|
| 0 | Листовой класс (нет наследников) |
| 1--5 | Нормальное наследование |
| 6--10 | Много наследников -- проверьте базовый класс |
| 10+ | Тяжёлый базовый класс -- рассмотрите композицию |
Почему это важно¶
Класс с большим количеством потомков -- это точка высокого воздействия изменений. Любая модификация родительского класса -- изменение сигнатуры метода, изменение поведения, добавление абстрактных методов -- затрагивает каждый дочерний класс. Чем больше потомков, тем рискованнее любое изменение.
Высокий NOC также может указывать на:
- Чрезмерную зависимость от наследования вместо композиции
- Потенциальное нарушение принципа подстановки Лисков -- все ли потомки действительно ведут себя как родитель?
- Трудности рефакторинга -- изменение базового класса требует обновления всех подклассов
Пороговые значения¶
| Значение | Серьёзность | Что означает |
|---|---|---|
| 0--9 | OK | Управляемое количество подклассов |
| 10--14 | Warning | Много потомков, изменения будут иметь широкое влияние |
| 15+ | Error | Слишком много потомков, рассмотрите использование интерфейсов |
Пример¶
abstract class BaseHandler
{
abstract public function handle(Request $request): Response;
protected function validate(Request $request): void { /* ... */ }
protected function authorize(Request $request): void { /* ... */ }
}
// 15 обработчиков наследуют BaseHandler -- NOC = 15 -> ERROR
class CreateUserHandler extends BaseHandler { /* ... */ }
class UpdateUserHandler extends BaseHandler { /* ... */ }
class DeleteUserHandler extends BaseHandler { /* ... */ }
class ListUsersHandler extends BaseHandler { /* ... */ }
class CreateOrderHandler extends BaseHandler { /* ... */ }
// ... ещё 10 обработчиков
Как исправить¶
- Используйте интерфейс вместо базового класса. Каждый класс реализует интерфейс независимо, поэтому изменение одного не влияет на остальные.
- Используйте паттерн Strategy. Вместо множества подклассов параметризуйте поведение через зависимости конструктора.
- Переместите общую логику в трейт, если вам всё ещё нужна общая функциональность без жёсткой связи наследования.
Детали реализации¶
NOC считает различные логические имена потомков из анализируемого графа
зависимостей, а не вхождения extends. Подкласс, объявленный дважды, считается
один раз. Каждое объявление именованного класса, включая абстрактный класс и
корень без рёбер, получает число потомков, в том числе 0. Интерфейсы, трейты и
enum не получают design.noc; interface B extends A не делает B потомком
A. Собственные родительские рёбра анонимных классов не приписываются их
именованному владельцу. Граф, который читает NOC, не содержит канонических
саморёбер.
Подсчёт NOC по логическим именам не зависит от наличия числового DIT. Класс с петлёй в цепочке предков всё равно получает NOC. Агрегаты пространства имён и проекта сохраняют агрегацию точных объявлений: каждое повторное объявление родителя публикует одно и то же логическое число потомков.
Отклонение от оригинальной спецификации
Агрегаты NOC пространства имён и проекта берут физические объявления классов, повторяя логическое число потомков для каждого объявления имени. Их суммы, количества и средние взвешены по объявлениям, а не по логическим вершинам графа или рёбрам наследования.
Настройка¶
bin/qmx check src/ --rule-opt="design.noc:warning=12"
bin/qmx check src/ --rule-opt="design.noc:error=20"
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
Глубина наследования¶
Rule ID: design.dit
Судимая метрика: design.dit
Что измеряет¶
Это правило считает, сколько уровней родительских классов имеет класс. Эта метрика называется глубиной дерева наследования (DIT -- Depth of Inheritance Tree).
class A {}-- DIT = 0 (нет родителя)class B extends A {}-- DIT = 1class C extends B {}-- DIT = 2class D extends C {}-- DIT = 3
Как читать значение:
| DIT | Интерпретация |
|---|---|
| 0 | Корневой класс (нет родителя) |
| 1--3 | Нормальная глубина |
| 4--6 | Глубокая иерархия -- может быть хрупкой |
| 6+ | Очень глубокая -- хрупкая, трудно понять |
Почему это важно¶
Когда вы читаете класс, расположенный глубоко в дереве наследования, вам нужно понять все его родительские классы, чтобы разобраться в его поведении. Каждый уровень добавляет неявное поведение: унаследованные методы, переопределённые методы, разделяемое состояние, побочные эффекты конструкторов.
Класс с DIT = 6 означает, что вам потенциально нужно прочитать 7 классов, чтобы понять его полное поведение. Это трудно, подвержено ошибкам и делает код устойчивым к изменениям.
Пороговые значения¶
| DIT | Серьёзность | Что означает |
|---|---|---|
| 0--3 | OK | Приемлемая глубина наследования |
| 4--5 | Warning | Становится глубоко, проверьте необходимость наследования |
| 6+ | Error | Слишком глубоко, вероятно проблема дизайна |
Пример¶
class BaseEntity {} // DIT = 0
class TimestampedEntity extends BaseEntity {} // DIT = 1
class SoftDeletableEntity extends TimestampedEntity {} // DIT = 2
class AuditableEntity extends SoftDeletableEntity {} // DIT = 3
class VersionedEntity extends AuditableEntity {} // DIT = 4 -> Warning
class TenantEntity extends VersionedEntity {} // DIT = 5 -> Warning
class UserEntity extends TenantEntity {} // DIT = 6 -> Error!
Чтобы понять UserEntity, нужно прочитать все 7 классов в цепочке.
Как исправить¶
-
Предпочитайте композицию наследованию. Вместо цепочки базовых классов внедряйте поведение через зависимости:
-
Используйте интерфейсы + трейты для общего поведения, не требующего глубоких иерархий:
-
Уплощайте иерархию. Спросите себя, действительно ли каждый промежуточный класс необходим, или его можно объединить с родителем или потомком.
Примечание
Базовые классы фреймворков (например, сущности Doctrine или контроллеры Symfony) учитываются в DIT. Если ваш фреймворк навязывает 2--3 уровня наследования, скорректируйте пороговые значения соответственно.
Детали реализации¶
DIT вычисляется один раз по фактам объявлений именованных классов и их
декларационным рёбрам extends, включая абстрактные классы и корни с глубиной
0. Каждое точное объявление сохраняет своего родителя, даже если имя объявлено
несколько раз в одном файле. Интерфейсы, трейты, enum и анонимные классы не
получают DIT. Родитель анонимного класса не становится родителем охватывающего
именованного класса.
Зарегистрированные встроенные PHP-родители обходятся транзитивно по статической
иерархии даже без Composer-установки: класс, наследующий \RuntimeException,
имеет DIT 2, а наследующий \ArgumentCountError — DIT 3. Имена
App\Exception и App\Error принадлежат проекту. Незарегистрированные классы
расширений требуют доступного исходного кода; набор загруженных расширений
анализирующей машины не меняет измерение.
Остальные родители вне анализируемого пути обходятся чтением файлов через Composer-карту исходников вашего проекта. Анализируемый код не загружается. Предел в 64 посещения действует на каждый последовательный внешний участок. Если последнее разрешённое родительское ребро достигло уже проанализированного имени, цепочка продолжается по фактам графа без лишнего внешнего чтения; оставшееся внешним имя за пределом оставляет нижнюю границу. Результат различает три исхода:
- Точный: цепочка дошла до корня;
design.dit-unresolvedравен 0. - Нижняя граница: исходник нельзя разместить или прочитать, установка не
предоставляет его, либо внешний обход достиг предела в 64 шага.
design.ditсохраняет известную нижнюю границу, аdesign.dit-unresolvedравен 1. Находка и рекомендация говорят "DIT is at least N", когда эта граница достигает настроенного порога. - Петля: цепочка дошла до канонического цикла наследования, включая
наследование класса от самого себя. Числовой
design.ditне публикуется, аdesign.dit-unresolvedравен 1. У этого класса и его потомков нет числовой находки DIT. Длина петли не является глубиной наследования.
У имени родителя может быть несколько объявлений, включая корень рядом с объявлением с родителем. Участвуют все варианты: конечные глубины объединяются максимумом, любая нижняя граница делает ответ нижней границей, а любая петля убирает числовую глубину. Порядок обхода не влияет на результат. Запись класса и находка сохраняют точную идентичность объявления. Это действует и при выходе цепочки за анализируемый путь и возвращении: участвуют все известные объявления достигнутого имени, а не только исходник, выбранный Composer. Известный внешний участок учитывается один раз; смешанная петля также убирает числовую глубину.
Агрегаты DIT используют публикуемые числа, включая корни и нижние границы; объявления с петлёй не дают числовой выборки.
Включённое правило design.dit пишет не более одного предупреждения за
исполнение, различая нижние границы, петли и их сочетание. Оно называет
design.dit-unresolved, до пяти разных пар «причина/класс» с настоящим адресом
обрыва или петли и число оставшихся пар. Отсутствие установки Composer
отличается от отсутствия класса в существующей установке. Отключение правила
заглушает предупреждение, а метрики продолжают собираться. Предупреждения идут
в поток ошибок; -q и --log-level=error заглушают их, машинные форматы остаются
разбираемыми.
Настройка¶
bin/qmx check src/ --rule-opt="design.dit:warning=5"
bin/qmx check src/ --rule-opt="design.dit:error=7"
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
Покрытие типами параметров (Parameter Type Coverage)¶
Rule ID: design.type-coverage.param
Судимая метрика: design.type-coverage.param
Что измеряет¶
Процент параметров методов и функций класса, у которых объявлен тип.
Сырые счётчики типизированных и общих параметров, возвращаемых значений и свойств сохраняют измеренный 0. При нулевом общем числе типизируемых объявлений процентные метрики отсутствуют, а не равны 100%; при положительном общем числе и отсутствии типов публикуется 0%. Встроенный typing health использует фактическую сумму знаменателей.
Как и два правила ниже, это правило использует инвертированные пороги: меньшие значения хуже. Предупреждение выдаётся, когда покрытие падает ниже порога warning, а ошибка -- когда падает ниже порога error. Класс без параметров типизировать нечего, и он не сообщается никогда.
Как читать значение:
| Покрытие | Интерпретация |
|---|---|
| 0--49% | Низкое покрытие типами |
| 50--79% | Умеренное покрытие типами |
| 80--100% | Хорошее покрытие типами |
Три правила, а не одно
Параметры, возвращаемые значения и свойства раньше были тремя каналами одного правила design.type-coverage с одним набором опций. Теперь это три правила, у каждого свой порог, своё подавление и своя запись в бейзлайне: кодовая база обычно типизирует их с разной скоростью -- см. примечание о миграции.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
// у $data нет типа -> снижает покрытие параметров
public function process($data)
{
// ...
}
public function reset(int $attempts): void
{
// типизировано -- хорошо
}
}
// Покрытие параметров: 50% (1 из 2) -> Warning
Как исправить¶
Добавьте объявления типов параметрам:
Типизируйте постепенно
Начните с типизации нового кода, а существующий типизируйте по ходу рефакторинга. PHP 8.0+ поддерживает union-типы (string|int), PHP 8.1+ -- intersection-типы (Countable&Iterator) для неудобных случаев.
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.param:warning=90"
bin/qmx check src/ --param-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.param:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Покрытие типами возвращаемых значений (Return Type Coverage)¶
Rule ID: design.type-coverage.return
Судимая метрика: design.type-coverage.return
Что измеряет¶
Процент методов и функций класса, у которых объявлен тип возвращаемого значения. Пороги инвертированные, ровно как у покрытия типами параметров: меньше -- хуже.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
// нет типа возврата -> снижает покрытие возвращаемых значений
public function process(array $data)
{
// ...
}
public function reset(): void
{
// тип возврата есть -- хорошо
}
}
// Покрытие возвращаемых значений: 50% (1 из 2) -> Warning
Как исправить¶
Объявите, что метод возвращает; используйте void, когда он не возвращает ничего, и never, когда он всегда бросает исключение или завершает процесс.
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.return:warning=90"
bin/qmx check src/ --return-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.return:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Покрытие типами свойств (Property Type Coverage)¶
Rule ID: design.type-coverage.property
Судимая метрика: design.type-coverage.property
Что измеряет¶
Процент объявленных свойств класса, у которых есть тип. Пороги инвертированные, ровно как у покрытия типами параметров: меньше -- хуже.
Пороговые значения¶
| Warning (ниже) | Error (ниже) |
|---|---|
| 80% | 50% |
Пример¶
class LegacyService
{
private $cache; // нет типа -> снижает покрытие свойств
public bool $debug = true; // типизировано -- хорошо
}
// Покрытие свойств: 50% (1 из 2) -> Warning
Как исправить¶
Типизируйте свойства, а те, что присваивает конструктор, объявляйте через promotion:
Конфигурация¶
bin/qmx check src/ --rule-opt="design.type-coverage.property:warning=90"
bin/qmx check src/ --property-type-coverage-error=60
Простой порог вместо раздельных уровней warning/error
(threshold нельзя сочетать с warning или error — смешение считается
ошибкой конфигурации, и прогон останавливается с кодом 3):
rules:
design.type-coverage.property:
threshold: 80 # warning=80, error=80 → все нарушения ниже 80% становятся ошибками
Класс данных (Data Class)¶
Идентификатор правила: design.data-class
Судимая метрика: design.woc
Серьезность: Warning
Что измеряет¶
Обнаруживает классы, публичный интерфейс которых в основном отдаёт данные, а не несёт поведение. WOC (Weight of Class, Lanza & Marinescu) -- это доля публичного интерфейса, несущая поведение: функциональные публичные методы, делённые на все публичные члены -- публичные методы, включая аксессоры, плюс публичные свойства. Data-класс сочетает низкий WOC с низким WMC (Weighted Methods per Class): он выставляет состояние наружу и мало что с ним делает.
Намеренные DTO исключаются: readonly-классы и классы только с promoted properties не помечаются, как и интерфейсы, абстрактные классы, классы исключений и классы без свойств. Трейты не исключаются: трейт с полями и их аксессорами это data-класс, размазанный по единице переиспользования.
Как классифицируется метод
Аксессор определяется и по имени get*/is*/has*/set* (включая голый префикс), и по простому телу чтения либо записи свойства. Пересылающий вызов или вычисляющий get* — поведение: одного префикса недостаточно, чтобы убрать метод из числителя функциональных методов WOC. WOC описывает форму публичного интерфейса, а не вес работы за ним. Конструктор не входит ни в числитель, ни в знаменатель: у Lanza & Marinescu функциональный метод это метод, не являющийся ни аксессором, ни конструктором. Класс вообще без публичных членов получает 100 и никогда не помечается. Считаются только члены, объявленные самим классом, — унаследованные и подмешанные трейтом невидимы.
Пороговые значения¶
| Метрика | Условие | По умолчанию |
|---|---|---|
| WOC | ≤ порога | 33% |
| WMC | ≤ порога | 10 |
| Минимум членов | ≥ | 3 |
Граница включающая: ровно 33% это срабатывание. Обе метрические оси -- верхние границы, поэтому в @qmx-threshold design.data-class W E
граница WOC может быть ниже границы WMC, и это не ошибка порядка.
Минимум членов считает все объявленные методы (включая аксессоры) плюс все объявленные свойства: структура из публичных полей вообще не объявляет методов и всё равно должна попадать в область правила.
Пример¶
// Помечается: весь публичный интерфейс -- доступ к данным, не readonly
class UserProfile
{
private string $name;
private string $email;
private string $phone;
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getPhone(): string { return $this->phone; }
public function setPhone(string $phone): void { $this->phone = $phone; }
}
// Не помечается: намеренный DTO (readonly)
readonly class UserDTO
{
public function __construct(
public string $name,
public string $email,
) {}
}
Как исправить¶
- Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса. Замена пары геттер/сеттер на метод, выражающий саму операцию, напрямую поднимает WOC.
- Превратите в DTO -- если класс намеренно является только данными, сделайте его
readonlyдля выражения намерения. - Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.
Исключения распознаются по разрешённой цепочке предков до PHP Throwable,
включая потомков внутри проекта и доступных внешних родителей, а не по короткому
имени родителя. design.is-exception равен 1 для доказанного исключения, 0 для
доказанного не-исключения и отсутствует при неизвестной цепочке. Варианты
повторных объявлений родителя должны согласоваться; известная глубина не
гарантирует известный статус исключения. У интерфейсов классификация исключения
отсутствует: их граф extends не является цепочкой наследования классов.
У трейтов и enum публикуется 0.
При exclude_exceptions: true (по умолчанию) пропускаются и доказанные
исключения, и неизвестный статус. При false правило судит остальные критерии
WOC, WMC и количества членов независимо от классификации исключения.
Конфигурация¶
# qmx.yaml
rules:
design.data-class:
woc_threshold: 33
wmc_threshold: 10
min_members: 3
exclude_readonly: true
exclude_promoted_only: true
exclude_exceptions: true # пропустить исключения и неизвестную цепочку
God Class (Божественный класс)¶
Идентификатор правила: design.god-class
Серьезность: Warning (3+ критерия) / Error (все оцениваемые критерии)
Что измеряет¶
Обнаруживает God-классы -- чрезмерно сложные, крупные классы с низкой связностью. Использует мульти-критериальный подход Lanza & Marinescu: класс помечается, когда он соответствует минимум minCriteria из до 4 оцениваемых критериев.
Критерии (всего 4):
| Критерий | Условие | По умолчанию | Описание |
|---|---|---|---|
| WMC | ≥ порога | 47 | Взвешенные методы класса |
| LCOM4 | ≥ порога | 3 | Недостаток связности |
| TCC | < порога | 0.33 | Тесная связность класса (инвертировано) |
| Class LOC | ≥ порога | 300 | Физические строки кода |
Недостающие метрики уменьшают количество оцениваемых критериев. Если оцениваемых критериев меньше minCriteria, нарушение не создаётся.
Пример¶
// Помечается: высокий WMC, высокий LCOM, низкий TCC, большой размер
class ApplicationManager
{
// 400+ LOC, 25 методов, обрабатывает:
// - аутентификацию пользователей
// - управление сессиями
// - маршрутизацию запросов
// - форматирование ответов
// - обработку ошибок
// - логирование
// - кеширование
}
Как исправить¶
- Извлеките классы по ответственности -- определите кластеры методов, работающих с одними данными, и выделите их в отдельные классы.
- Применяйте принцип единственной ответственности -- каждый класс должен иметь одну причину для изменения.
- Используйте композицию -- замените иерархии наследования составными объектами.