Перейти к содержанию

Правила проектирования

Правила проектирования анализируют внутреннюю структуру ваших классов -- насколько они сфокусированы, как используется наследование и не взяли ли классы на себя слишком много ответственности. Эти правила помогают обнаружить структурные проблемы до того, как их исправление станет дорогим.


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 пространства имён и проекта берут физические объявления классов, повторяя логическое число потомков для каждого объявления имени. Их суммы, количества и средние взвешены по объявлениям, а не по логическим вершинам графа или рёбрам наследования.

Настройка

# qmx.yaml
rules:
  design.noc:
    warning: 12
    error: 20
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):

rules:
  design.noc:
    threshold: 12   # warning=12, error=12 → все нарушения становятся ошибками
bin/qmx check src/ --rule-opt="design.noc:threshold=12"

Глубина наследования

Rule ID: design.dit

Судимая метрика: design.dit

Что измеряет

Это правило считает, сколько уровней родительских классов имеет класс. Эта метрика называется глубиной дерева наследования (DIT -- Depth of Inheritance Tree).

  • class A {} -- DIT = 0 (нет родителя)
  • class B extends A {} -- DIT = 1
  • class C extends B {} -- DIT = 2
  • class 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 классов в цепочке.

Как исправить

  • Предпочитайте композицию наследованию. Вместо цепочки базовых классов внедряйте поведение через зависимости:

    class UserEntity
    {
        public function __construct(
            private Timestamps $timestamps,
            private SoftDelete $softDelete,
            private AuditLog $auditLog,
        ) {}
    }
    
  • Используйте интерфейсы + трейты для общего поведения, не требующего глубоких иерархий:

    class UserEntity implements Timestamped, SoftDeletable
    {
        use TimestampsTrait;
        use SoftDeleteTrait;
    }
    
  • Уплощайте иерархию. Спросите себя, действительно ли каждый промежуточный класс необходим, или его можно объединить с родителем или потомком.

Примечание

Базовые классы фреймворков (например, сущности 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 заглушают их, машинные форматы остаются разбираемыми.

Настройка

# qmx.yaml
rules:
  design.dit:
    warning: 5
    error: 7
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):

rules:
  design.dit:
    threshold: 5   # warning=5, error=5 → все нарушения становятся ошибками
bin/qmx check src/ --rule-opt="design.dit:threshold=5"

Покрытие типами параметров (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

Как исправить

Добавьте объявления типов параметрам:

public function process(array $data)
{
    // ...
}

Типизируйте постепенно

Начните с типизации нового кода, а существующий типизируйте по ходу рефакторинга. PHP 8.0+ поддерживает union-типы (string|int), PHP 8.1+ -- intersection-типы (Countable&Iterator) для неудобных случаев.

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.param:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.param:threshold=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, когда он всегда бросает исключение или завершает процесс.

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.return:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.return:threshold=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:

public function __construct(private readonly CacheInterface $cache) {}

Конфигурация

# qmx.yaml
rules:
  design.type-coverage.property:
    warning: 80
    error: 50
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% становятся ошибками
bin/qmx check src/ --rule-opt="design.type-coverage.property:threshold=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,
    ) {}
}

Как исправить

  1. Инкапсулируйте поведение -- перенесите операции, использующие эти данные, внутрь самого класса. Замена пары геттер/сеттер на метод, выражающий саму операцию, напрямую поднимает WOC.
  2. Превратите в DTO -- если класс намеренно является только данными, сделайте его readonly для выражения намерения.
  3. Объедините с потребителем -- если класс только хранит данные для другого класса, рассмотрите встраивание.

Исключения распознаются по разрешённой цепочке предков до 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   # пропустить исключения и неизвестную цепочку
bin/qmx check src/ --rule-opt="design.data-class:exclude_exceptions=false"

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 методов, обрабатывает:
    // - аутентификацию пользователей
    // - управление сессиями
    // - маршрутизацию запросов
    // - форматирование ответов
    // - обработку ошибок
    // - логирование
    // - кеширование
}

Как исправить

  1. Извлеките классы по ответственности -- определите кластеры методов, работающих с одними данными, и выделите их в отдельные классы.
  2. Применяйте принцип единственной ответственности -- каждый класс должен иметь одну причину для изменения.
  3. Используйте композицию -- замените иерархии наследования составными объектами.

Конфигурация

# qmx.yaml
rules:
  design.god-class:
    wmc_threshold: 47
    lcom_threshold: 3
    tcc_threshold: 0.33
    class_loc_threshold: 300
    min_criteria: 3
    min_methods: 3   # по умолчанию: классы с меньшим числом методов никогда не помечаются
    exclude_readonly: true
bin/qmx check src/ --rule-opt="design.god-class:min_methods=5"